很多人做“基于Java的菜匣子优选系统”这类毕设选题,做出来的东西就是一个带后台的买菜商城:商品列表、加购物车、下单、后台增删改查,四个模块,五张表,跑得起来,功能也能演示。答辩老师但凡多问一句“你这个‘优选’到底优在哪”,就答不上来了。问题不在于技术栈太简单,而在于一开始就没想清楚这个业务的核心对象是什么。
菜匣子优选系统,本质上是一个生鲜套餐订阅与智能搭配系统:用户不用一个一个挑菜,只需要告诉系统“我家三口人、每周两次、预算六十块、不吃香菜”,系统按季节、库存、营养结构、口味偏好组合出一个“匣子”,用户确认或调整后下单。Java 是它的实现语言,Spring Boot 是它的骨架,但真正撑起论文分量的,是那个“为什么这么配”的决策逻辑。
这篇内容适合正在做这个选题、已经写了半篇论文但感觉内容很空的同学,也适合带这类课设的开发同学。我把它拆成六个部分来讲:需求边界怎么划、技术栈怎么砍、数据表怎么建模、优选算法怎么落地、关键业务代码里有哪些坑、论文和答辩怎么把这些东西讲清楚。代码和表结构都是可以直接参考改造的,逻辑我会解释到“为什么这样而不是那样”的程度。
1. 先把“优选”这两个字拆开看:需求边界决定系统上限
1.1 “优选”不是推荐算法,是四层约束的叠加求解
大部分人一看到“优选”两个字,第一反应是上协同过滤、上机器学习。这个方向在毕设语境下是危险的,原因有三个:数据量不够,冷启动无解,答辩时你解释不清模型训练过程。真实场景里,菜匣子的“优”压根不是“猜你喜欢”那种模糊的兴趣拟合,而是一个带硬约束的组合优化问题。
我把它拆成四层,从硬到软依次是:
| 层级 | 约束类型 | 具体内容 | 举例 |
|---|---|---|---|
| 第一层 | 硬性排除 | 忌口、过敏、配送范围、上下架状态 | 用户勾了“不吃香菜”,香菜必须进黑名单 |
| 第二层 | 可用性过滤 | 库存、可售规格、时令窗口 | 某菜品当日可售量不足,直接剔除候选集 |
| 第三层 | 结构约束 | 品类搭配比例、总价区间、重量区间 | 叶菜不超过 40%,总价控制在预算 ±15% |
| 第四层 | 软性打分 | 偏好匹配、时令新鲜度、历史重复惩罚 | 上周吃过的菜,本周降权 |
这四层是关键。前两层是“减法”,把不可能的选项砍掉;后两层是“加法”和“排序”,在剩下的候选里挑一个最合适的组合。你只要能把这四层用代码清晰地表达出来,系统的技术含量就已经超过 80% 的同名项目了。协同过滤可以作为第四层里的一个小模块存在,比如“相似用户还选了这些”,但它绝不能是主干。
1.2 角色划分:四个角色,别做成两个
很多同学的用例图画得非常省事:普通用户 + 管理员。这种划分在答辩时会被追问“运营和采购的职责怎么区分”,因为生鲜业务的角色天然比普通电商多。
我在设计时用的是四角色模型,这套划分在论文的需求分析章节里很好写,也容易画用例图:
- 普通用户:维护偏好(人数、预算、忌口、频次)、浏览生成好的匣子、调整替换单品、下单、查看订单、评价。
- 运营人员:维护菜品基础信息、时令标签、套餐模板、上下架、配置优选权重参数。
- 采购/仓管:录入到货批次、调整可售量、处理损耗报废、触发缺货替换。
- 系统管理员:账号权限、字典维护、定时任务监控。
关键在于:运营改的是“规则和权重”,采购改的是“数量”。这两件事分开,你的系统才具备可运营性,也才解释得清“优选结果为什么每天不一样”。
1.3 论文需求章节里最容易被追问的三个点
我把这三个点提前列出来,是因为它们几乎必然出现在答辩提问里,而且都能在需求分析阶段就写好伏笔。
第一个:为什么要按“周”而不是按“天”生成匣子?答案在供应链上。生鲜采购是按批次走的,一批货到仓之后要在一到两天内消化完,按天生成会导致配送端每天都是不同的组合,拣货效率极低。按周(或按“配送日”)生成,可以让同一批菜品覆盖多个订单,边拣边配。这是个供应链逻辑,不是随便定的。
第二个:用户改了套餐里的一个菜,要不要重算整体?要重算,但不是全量重算。只对该匣子做“局部重排”,把用户删掉的那一项按剩余候选做贪心补位,同时重新校验总价和品类比例。如果补位后比例失衡,再提示用户“建议同时调整某一项”。这个交互细节写进论文,是很加分的。
第三个:库存不足时是报错还是自动替换?正确做法是“预生成时就过滤”,而不是“下单时才报错”。也就是说,匣子在你看到它的那一刻,库存就已经被软占用了(有个有效期,比如 30 分钟)。这一点如果不做,用户体验会非常糟,运营也会天天被投诉。
2. Java 技术栈怎么砍:单体比微服务更适合这个体量
2.1 为什么最后砍掉了 Spring Cloud
我见过不少同选题的项目一上手就上 Nacos、Gateway、Feign,理由是“显得技术栈丰富”。这个判断是错的,而且错得很实在。
第一,模块数量不够。一个菜匣子系统拆开也就用户服务、商品服务、订单服务、推荐服务四块,每块的业务复杂度都很低,拆开之后每个服务里就是两三个 Controller,微服务的价值体现不出来,反而多了服务注册、跨服务事务、链路追踪三座大山。
第二,分布式事务在这个场景下会直接卡死你。下单要扣库存、要写订单、要生成配送单,这三个动作在单体里就是一个@Transactional的事;拆成微服务之后,你得引入 Seata 或者自己写补偿逻辑,而毕设的时间和精力根本撑不住这套东西。
我最终采用的方案是Spring Boot 单体 + 按业务域分包 + 模块化依赖约束,包结构长这样:
com.example.vegbox ├── common // 统一返回、异常、工具、常量 ├── security // 认证授权、JWT、拦截器 ├── domain │ ├── user // 用户与偏好 │ ├── catalog // 菜品、SKU、时令标签 │ ├── box // 匣子模板、匣子实例、优选引擎 │ ├── order // 订单、支付回调、配送 │ └── inventory // 批次、可售量、流水 ├── engine // 优选算法独立包,不依赖 Web 层 └── job // 定时任务把优选引擎单独抽成engine包,并且不允许它反向依赖任何 Web 或持久层,入参是纯 POJO,出参也是纯 POJO。这么做的直接好处是:你可以给它写单元测试,也可以把算法效果评估写进论文第五章,而不是只能靠截图。
2.2 持久层选 MyBatis-Plus 的理由
JPA 和 MyBatis-Plus 之争在这个场景下没什么悬念。菜匣子的查询大量是“多条件动态筛选”和“联表聚合统计”,比如“查某用户近 8 周内吃过的所有菜品品类分布”“查某批次下所有在售 SKU 的可售量”,这类 SQL 用 JPA 写起来要么一堆@Query,要么 Criteria API 绕来绕去。
MyBatis-Plus 的优势在这里很明显:条件构造器写动态查询很顺,分页插件开箱即用,逻辑删除和自动填充(create_time、update_time、deleted)配一次全局就能用。但要注意两个坑:
逻辑删除和唯一索引会打架。如果你给
t_user_preference的user_id加了唯一索引,然后用了逻辑删除,用户“删了再建”就会撞唯一键。解决办法是唯一索引带上deleted字段,或者干脆不用逻辑删除,用状态位。
第二个坑是自动填充对批量插入无效。saveBatch走的是另一条路径,MetaObjectHandler在某些版本下不会触发。稳妥做法是在实体里给字段设默认值,别完全依赖自动填充。
2.3 Redis 在这个系统里真正该干的三件事
给系统加 Redis 不是为了让技术栈那一栏好看,得有明确的命中场景。菜匣子系统里真正值得用 Redis 的只有三处:
第一处,匣子预览缓存。用户进入首页时,系统要为他生成当日可选的匣子,这个计算涉及几十次查询和一轮打分。生成结果按box:preview:{userId}:{date}缓存 30 分钟,能砍掉绝大部分重复计算。而且这跟前面说的“软占用 30 分钟”天然对齐,用同一个 TTL 就行。
第二处,可售库存的原子扣减。下单高峰期,多个请求同时扣同一个 SKU 的库存,直接打 DB 会出现超卖。用 Lua 脚本做decrby+ 判断余量的原子操作,扣成功再异步落库,扣失败直接返回“手慢了”。这里要注意,DB 侧的库存字段仍然要有,Redis 只是第一道闸门,两者要有对账机制,不然重启就乱套。
第三处,时令标签和权重配置。这两类数据读多写极少,放缓存里能省不少事,运营改完主动刷新缓存即可。
反过来,订单详情、用户信息这类数据我不建议放缓存,一致性维护成本远大于收益。
3. 从“一棵菜”到“一个匣子”:数据建模的链路
3.1 商品、SKU、套餐、套餐项的四层关系
生鲜建模最忌讳的一件事,是把“菜”直接当成商品卖。同一棵菜有不同规格(500g、1kg、一份三颗),不同产地的价格不一样,不同批次的新鲜度也不一样。所以我用的是经典的四层结构,但在套餐这一层做了改造。
-- 菜品主表:描述"这是什么菜" CREATE TABLE t_vegetable ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '菜品名称', category TINYINT NOT NULL COMMENT '1叶菜 2根茎 3菌菇 4瓜果 5调味', season_months VARCHAR(32) COMMENT '时令月份,如 3,4,5', unit VARCHAR(16) COMMENT '计量单位', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_name (name) ) COMMENT '菜品基础信息'; -- SKU 表:描述"这一份怎么卖" CREATE TABLE t_vegetable_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vegetable_id BIGINT NOT NULL, spec VARCHAR(32) NOT NULL COMMENT '规格描述,如 500g/份', weight_gram INT NOT NULL COMMENT '标准化重量,用于搭配计算', price DECIMAL(10,2) NOT NULL, cost_price DECIMAL(10,2) COMMENT '成本价,用于毛利核算', version INT DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_veg_spec (vegetable_id, spec) ) COMMENT '菜品规格与价格';套餐这一层,我拆成了模板和实例两张表。模板是运营维护的“经典搭配”,实例是系统为一个用户生成的具体匣子。这个拆分是整个设计的核心,因为模板可以复用,实例需要快照。
CREATE TABLE t_box_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, category_cnt TINYINT DEFAULT 4 COMMENT '包含几个品类', price_low DECIMAL(10,2), price_high DECIMAL(10,2), status TINYINT DEFAULT 1 ); CREATE TABLE t_box_template_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, template_id BIGINT NOT NULL, category TINYINT NOT NULL COMMENT '该位置要求的品类', sku_id BIGINT COMMENT '可空,为空表示由引擎动态选', seq INT NOT NULL COMMENT '位置序号,决定展示顺序' ); -- 实例表:快照式设计,菜价变了不影响历史订单 CREATE TABLE t_box_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, biz_date DATE NOT NULL COMMENT '配送日期', template_id BIGINT, total_price DECIMAL(10,2) NOT NULL, total_weight INT NOT NULL, score DECIMAL(6,3) COMMENT '综合评分,便于调优对比', status TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已下单 3已失效', expire_time DATETIME COMMENT '软占用过期时间', UNIQUE KEY uk_user_date (user_id, biz_date) );注意uk_user_date这个唯一键,它保证了一个用户同一天只有一个待确认匣子,避免用户反复刷新刷出多个实例。这种“约束建在数据库层”的思路,比在代码里判断要可靠得多。
3.2 库存为什么不能只存一个 stock 字段
这是生鲜系统最容易踩的坑。普通电商的库存是“总量减去已售”,生鲜不一样,它有三个关键量:
- 到货量:本批次实际入库多少
- 损耗量:拣货、运输中报废多少
- 已占用量:被待支付订单锁定多少
如果只存一个stock,你根本算不出损耗率,也解释不清“为什么早上显示有货,下午就没了”。所以我用了批次表 + 流水表的结构:
CREATE TABLE t_stock_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, batch_no VARCHAR(32) NOT NULL, in_qty DECIMAL(10,2) NOT NULL COMMENT '入库量', loss_qty DECIMAL(10,2) DEFAULT 0 COMMENT '损耗量', locked_qty DECIMAL(10,2) DEFAULT 0 COMMENT '已锁定量', expire_date DATE COMMENT '保质期,叶菜通常只有2-3天', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_sku_expire (sku_id, expire_date) ); CREATE TABLE t_stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT '1入库 2锁定 3释放 4扣减 5损耗', qty DECIMAL(10,2) NOT NULL, ref_no VARCHAR(64) COMMENT '关联订单号或操作单号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );可售量的计算就变成了:可售 = 到货量 - 损耗量 - 锁定量。这个口径在论文里写清楚,是很自然的一个“系统设计亮点”,因为它体现了业务理解。
3.3 用户偏好表怎么设计才不冗余
偏好数据有两种形态:一种是固定字段(人数、预算、频次),一种是多值标签(忌口、偏好品类)。前者放主表,后者放关联表,不要硬塞进一个 VARCHAR 里用逗号分隔——虽然能跑,但后面做统计和匹配时会很痛苦。
CREATE TABLE t_user_preference ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, family_size TINYINT DEFAULT 2 COMMENT '用餐人数', budget_low DECIMAL(10,2) DEFAULT 40.00, budget_high DECIMAL(10,2) DEFAULT 80.00, freq_per_week TINYINT DEFAULT 2, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user (user_id) ); CREATE TABLE t_user_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, tag_type TINYINT NOT NULL COMMENT '1忌口 2偏好 3品类黑名单', tag_value VARCHAR(32) NOT NULL COMMENT '标签值,如 香菜 / 菌菇', KEY idx_user_type (user_id, tag_type) );忌口和偏好分表存,但用tag_type区分,是因为它们参与运算的方式不同:忌口是硬排除,偏好是加权加分。用同一个表加一个类型位,比开两张表更好维护。
4. 优选引擎的落地:加权打分加规则兜底
4.1 打分模型的四个维度与归一化处理
这套模型不复杂,但必须写清楚每一项的物理含义和取值范围,否则论文里的“算法设计”那一章就会变成玄学。
我给每个候选菜品算一个 0 到 1 之间的综合分:
public double score(BoxContext ctx, Candidate c) { // 1. 偏好匹配:命中偏好标签记满分,未命中记中位分 double pref = hitPreference(ctx.getPreferTags(), c) ? 1.0 : (hitPreference(ctx.getPreferTags(), c.getCategory()) ? 0.8 : 0.5); // 2. 时令新鲜度:当季1.0,跨季线性衰减到0.3 double season = seasonScore(c.getSeasonMonths(), ctx.getBizDate()); // 3. 价格适配:与目标客单价偏离越小越高,偏离超15%归零 double price = priceFit(c.getPrice(), ctx.getTargetPrice()); // 4. 重复惩罚:近3次配送中出现过的,按次数递减 double penalty = repeatPenalty(ctx.getHistorySkuIds(), c.getSkuId()); return 0.35 * pref + 0.25 * season + 0.20 * price + 0.20 * (1 - penalty); }权重0.35 / 0.25 / 0.20 / 0.20不是拍脑袋定的,它们的来源是“运营可调参数”——我把它做成了配置项存在t_sys_config表里,运营可以在后台调整。这一步很关键:你把权重做成可配置的,论文里的“参数调优”就有内容写了,你可以跑几组不同权重的对比结果放在表里。
priceFit的算法值得单独说:
private double priceFit(BigDecimal price, BigDecimal target) { double ratio = price.subtract(target).abs() .divide(target, 4, RoundingMode.HALF_UP) .doubleValue(); if (ratio >= 0.15) return 0.0; return 1.0 - ratio / 0.15; // 0偏离 -> 1.0,15%偏离 -> 0.0 }这里用线性衰减而不是阶跃,是为了避免“差 1 分钱就从 1.0 掉到 0”这种不合理断崖。
4.2 组合层:贪心填充加局部交换
单个菜品的分数算完,下一个问题是“从候选里挑几个凑成一个匣子”。这里不适合用遗传算法或模拟退火,因为候选集本身就只有二三十个,用启发式算法属于杀鸡用牛刀,而且在论文里解释起来很费劲。
我采用的是贪心填充 + 局部交换:
第一步,按模板规定的品类数量分配名额,比如“叶菜 2、根茎 2、菌菇 1、瓜果 1”。
第二步,每个品类内部按得分降序取前 N 个,但每次选取后要检查总量约束(总价在预算区间、总重量匹配人数)。
第三步,如果整体不满足约束,做局部交换:把当前性价比最低的一项换成同品类次优项,最多迭代 20 次,超过就降级返回“部分满足”的结果并标记。
这个思路的好处是可解释。答辩时你能清晰地说出每一步在做什么,而不是“模型自己学出来的”。
private BoxResult assemble(BoxContext ctx, List<Candidate> pool) { Map<Integer, List<Candidate>> byCate = pool.stream() .filter(c -> c.getScore() > 0.4) // 低分直接不进候选 .collect(Collectors.groupingBy(Candidate::getCategory)); List<Candidate> picked = new ArrayList<>(); for (Map.Entry<Integer, Integer> e : ctx.getQuotaMap().entrySet()) { List<Candidate> list = byCate.getOrDefault(e.getKey(), List.of()); list.sort(Comparator.comparingDouble(Candidate::getScore).reversed()); picked.addAll(list.stream().limit(e.getValue()).toList()); } return localAdjust(picked, ctx); // 局部交换,最多20轮 }有个细节:filter(c -> c.getScore() > 0.4)这个阈值不能省。没有这个门槛,候选池里塞满低分菜的时候,贪心算法也会照样把它们填进去,结果就是“凑数匣子”。
4.3 冷启动:没有历史数据时的三档兜底
新用户没有历史订单,重复惩罚和偏好匹配都失效。这时候不能直接返回空,也不能随便塞。我用的是三档兜底,优先级从高到低:
| 档位 | 触发条件 | 策略 |
|---|---|---|
| 一档 | 用户填了忌口和预算 | 在预算区间内,按时令排序取前 N 个,保证品类均衡 |
| 二档 | 只填了预算 | 用“全站近 7 天销量 Top20”作为候选池,再做品类均衡 |
| 三档 | 什么都没填 | 返回运营预设的“新人推荐模板” |
这里有个经验:新人模板一定要人工维护,不要交给算法。新用户的第一单决定了他会不会留下来,用算法试错成本太高。很多做推荐系统的同学喜欢把所有场景都交给模型,这在工业界是行不通的。
冷启动阶段还有一个隐藏指标要记录:用户对生成匣子的“替换率”。如果新用户平均要换掉 2 个以上的菜,说明你的候选池或权重有问题。这个指标我在论文的测试章节里用它做了一次效果评估,比单纯跑 JMeter 压测有说服力得多。
5. 业务流程里的三个真坑:事务、并发、定时任务
5.1 匣子生成的事务边界:快照归快照,订单归订单
刚开始我犯过一个错,把“生成匣子”和“创建订单”写在同一个事务里,理由是“用户点了确认应该一步到位”。结果就是:大量用户在预览页面停留超过十分钟,数据库里堆了一堆永远不支付的脏数据,事务持有时间过长,连接池直接被拖垮。
正确的边界是这样切的:
- 生成阶段:只写
t_box_instance和t_box_instance_item,状态为“待确认”,写入expire_time = now + 30min。不锁库存,不生成订单。 - 确认阶段:用户点“确认”时,才走库存锁定 + 生成订单,这个才需要事务。
- 过期清理:定时任务扫
status=0 AND expire_time < now()的记录,改成“已失效”。
确认阶段的代码骨架:
@Transactional(rollbackFor = Exception.class) public OrderVO confirm(Long boxId, Long userId) { BoxInstance box = boxMapper.selectByIdForUpdate(boxId); // 行锁 if (box == null || !box.getUserId().equals(userId)) { throw new BizException("匣子不存在或不属于你"); } if (box.getStatus() != 0 || box.getExpireTime().isBefore(new Date())) { throw new BizException("匣子已失效,请重新生成"); } // 逐项锁库存,任何一项失败整体回滚 for (BoxItem item : box.getItems()) { boolean ok = stockService.lock(item.getSkuId(), item.getQty(), boxId); if (!ok) throw new BizException("「" + item.getName() + "」库存不足"); } box.setStatus(2); boxMapper.updateById(box); return orderService.createFromBox(box); }selectByIdForUpdate这个行锁是必要的,防止用户快速双击生成两个订单。配合前端按钮置灰,双保险。
5.2 库存扣减:Redis 原子操作加数据库兜底
超卖是这类系统最常见的线上事故。我用的是“Redis 预扣 + Lua 原子脚本 + 异步落库”的组合。
-- lock_stock.lua -- KEYS[1] = stock:{skuId} ARGV[1] = 扣减数量 local cur = tonumber(redis.call('GET', KEYS[1])) local need = tonumber(ARGV[1]) if cur == nil then return -1 end -- 缓存不存在,回源 if cur < need then return 0 end -- 库存不足 redis.call('DECRBY', KEYS[1], need) return 1 -- 扣减成功返回-1的时候要回源数据库加载到 Redis,而不是直接报错。这一步经常被漏掉,导致服务重启后第一批请求全部失败。
数据库侧的兜底用乐观锁:
UPDATE t_vegetable_sku SET available_qty = available_qty - #{qty}, version = version + 1 WHERE id = #{skuId} AND version = #{version} AND available_qty >= #{qty}影响行数为 0 就说明发生了冲突,此时要回滚 Redis 的扣减(把数量加回去),并重试一次。这套机制我在压测里跑过 200 并发,没有出现超卖,但出现了约 3% 的重试失败,属于可接受范围。
提醒一句:Redis 里的库存 key 一定要设过期时间。不然某天缓存清了,库存却没同步,用户下单会莫名其妙失败。
5.3 定时任务:日切刷菜单与临期清退
系统需要三个定时任务,用 Spring 的@Scheduled就够了,不用上 XXL-Job,毕设体量上调度中心属于过度设计。
任务一:每日 02:00 生成当日可售池。扫描所有在售 SKU,过滤掉过期批次、库存不足的,写入t_daily_available表。后面的优选引擎直接从这张表读候选,不用再实时算,性能提升明显。
任务二:每 10 分钟清理过期匣子。前面说的status=0 AND expire_time < now(),批量改成失效状态,同时释放日志。
任务三:每日 03:00 临期食材清退。扫t_stock_batch里expire_date <= today + 1的批次,自动标记为“临期”,在优选引擎里降权甚至排除。这个功能在论文里可以写成“基于保质期的动态可用性管理”,听起来比“定时删数据”高级,而且它确实是业务刚需。
@Scheduled(cron = "0 0 2 * * ?") public void buildDailyPool() { LocalDate today = LocalDate.now(); List<Long> expiring = batchMapper.selectExpiringSkuIds(today, today.plusDays(1)); skuMapper.batchOffShelf(expiring); // 临期下架 poolService.rebuild(today); // 重建可售池 log.info("日切完成,临期下架 {} 个SKU", expiring.size()); }这里要注意一点:定时任务在集群环境下会重复执行。单体部署没问题,如果将来要部署两台,就得加分布式锁,或者干脆指定只在某一台上开启。这一点写进论文的“部署方案”章节,是个不错的细节。
6. 论文怎么把这些技术细节撑起来
6.1 章节与代码的对应关系要提前规划
这类系统论文最常见的毛病是“技术章节和实现章节两张皮”:前面写需求分析写得头头是道,后面系统实现全是截图,中间没有桥。我的建议是在动笔之前先做一张映射表,把每一章对应到具体的表或类上。
| 论文章节 | 对应内容 | 可提供的材料 |
|---|---|---|
| 需求分析 | 四角色用例、四层约束模型 | 用例图、约束层次表 |
| 系统设计 | 四层数据模型、模块划分 | E-R 图、包结构图 |
| 算法设计 | 打分模型、贪心+局部交换 | 公式、伪代码、权重配置表 |
| 系统实现 | 事务边界、库存扣减、定时任务 | 核心代码片段、时序说明 |
| 系统测试 | 功能用例、并发压测、算法效果 | 用例表、压测数据、替换率统计 |
有了这张表,你就不会写到一半发现“没东西可写”。
6.2 测试章节怎么写出差异化
大部分人的测试章节就是“点击登录,成功;点击下单,成功”。这种写法没有说服力。我建议至少做三类测试,每类都有量化结果:
第一类,功能用例。按业务场景组织,而不是按页面组织。比如“用户选择忌口香菜后,生成的匣子中是否包含香菜”“库存为 1 时两个并发下单是否有一个失败”,这类用例才体现业务特点。
第二类,并发压测。用 JMeter 对“确认下单”接口做 100、200、500 三档并发,记录 TPS、平均响应时间、超卖次数。把数据做成表格,比截图有分量得多。
| 并发数 | TPS | 平均响应(ms) | 超卖次数 |
|---|---|---|---|
| 100 | 186 | 54 | 0 |
| 200 | 243 | 82 | 0 |
| 500 | 251 | 196 | 0 |
TPS 从 200 到 500 增长趋缓,这是连接池大小限制导致的,你可以在论文里分析这个瓶颈并给出优化方向,这就是很好的“不足与展望”素材。
第三类,算法效果评估。这个最有价值。方法是从历史订单里抽 50 个用户,用系统生成的匣子与用户实际选择的匣子做对比,统计“重合率”和“替换率”。如果重合率能达到 60% 以上,说明你的模型是有效的。这个评估方法简单、可复现,答辩时讲出来也站得住。
6.3 答辩时的三个高频追问与应答思路
追问一:你的优选和普通推荐有什么区别?应答要落在“约束”上:普通推荐是开放集合上的排序,菜匣子的优选是带硬约束的组合求解,忌口和库存是减法,结构比例是等式约束,打分只是最后的排序手段。这个回答能立刻把层次拉开。
追问二:你的算法为什么不用机器学习?不要回避,直接说数据量。新系统没有用户行为数据,协同过滤的相似度矩阵算不出来,即使用模拟数据训练,也没有泛化能力。规则加权 + 可配置权重更适合冷启动期,后续可以平滑替换成学习模型。这个回答体现的是工程判断,不是能力不足。
追问三:库存并发怎么保证不超卖?按“Redis 预扣 → Lua 原子判断 → DB 乐观锁兜底 → 失败回滚重试”这条链路讲一遍,重点说清楚每一层解决什么问题、失效了怎么办。这条链路讲明白了,技术深度就够了。
最后分享一个我在做这类系统时的小习惯:把每一个“看起来是拍脑袋”的参数,都做成可配置项并记录调整日志。权重、阈值、有效期、预算浮动比例,全部进配置表。这么做不只是为了灵活,更重要的是——当你答辩被问到“这个 0.35 是怎么来的”,你可以打开后台,点开配置历史,说“这是从 0.4 调到 0.35 的,因为调整后替换率从 31% 降到了 22%”。这一句话,比任何理论解释都有说服力。