主题
商户详情价格逻辑审查
更新:已结合当前代码核验并实施修复,结果见 商户详情价格修复与验证。以下保留原始审查记录;原文“保存失败输入全部丢失”不成立,同价日期也不能一律视为无效行。P3-1 的修复保留房型由默认继承转为独立日期底价的行为;P3-4 同时补齐后端初始化与并发校验。原文的数字时间戳兼容、数字字符串不安全和库存测试全为正则断言等表述也已核验纠正。
审查日期:2026-10-11。审查范围为 AdminWeb 商户详情抽屉「结算底价」页签的四类价格编辑入口,并与 Api 后端契约逐项比对。
工作区基线 70a3495b,git status 干净。本轮为静态代码审查,未运行浏览器实操。
上一轮已修复的六项问题见 商户详情价格修复与验证,本轮不复述,只记录新发现问题。
一、结论
整体架构正确,四条价格链路可正常收敛到后端既有校验。 以下方面已逐项核对无误:
- 接口路径与后端路由完全一致(含
settlement-price-changes单数拼写、/sale-prices/、/inventory/两套独立前缀)。 - 三套接口的乐观锁字段名各自正确:结算底价用
expectedOldPriceFen;平台售价用expectedBasePriceFen+expectedSalePriceFen+expectedStock;日期库存用expectedStock+expectedSold。 MANAGED_PRODUCT在编辑售价时先经getMerchantSalePriceTargets展开为PRODUCT_ACTIVITY,提交时用active.targetType而非原始类型,与后端 PUT 拒绝MANAGED_PRODUCT的行为一致。MERCHANT_CAR行不含saleEditable键,「修改售价」按钮因此正确隐藏。checkPermi(['xl:merchant:inventory:edit'])与后端@PreAuthorize的 SpEL 条件对应,售价弹窗不会越权改库存。- 最低可设售价取
minimumSalePriceFen ?? basePriceFen,对应PriceCalendarInvariant.minimumProductSale的日期补款语义。 AmountInput未声明disabledprop,但 Vue 会把未声明的属性透传到根组件el-input的同名disabledprop(Element Plus 2.13.1 已声明),因此:disabled="saving"实际生效。el-radio-button value=在 Element Plus 2.13.1 下是正确用法(2.6+ 由label改为value)。String(row.date).slice(0, 10)能同时兼容字符串日期和时间戳两种序列化;Jackson2ObjectMapperBuilder默认关闭WRITE_DATES_AS_TIMESTAMPS,后端返回 ISO 字符串,此处属防御性冗余而非缺陷。
存在 3 个高危缺陷、2 个中等问题和 5 个低危一致性问题。 高危问题集中在「结算底价」页签的日期底价与默认底价两个弹窗,且都属于"前端未拦截 → 后端整批回滚"的模式,会造成管理员输入丢失。
现有测试 tests/merchant-settlement-price-contract.test.mjs 为纯 readFileSync + 正则存在性断言,无法覆盖本轮任何一个问题,详见第六节。
二、问题总览
| 编号 | 严重度 | 问题 | 位置 |
|---|---|---|---|
| P1-1 | 高 | 日期底价可选历史日期,整批回滚 | merchant/index.vue:568、:571-572 |
| P1-2 | 高 | 修改底价无上限校验,只能靠服务端报错发现 | merchant/index.vue:553-561、:1397-1411 |
| P1-3 | 高 | 乐观锁失败后弹窗不刷新,形成死循环 | merchant/index.vue:1394、:1454 |
| P2-1 | 中 | 空金额渲染成 ¥0.00 而非 - | merchant/index.vue:384、:575 |
| P2-2 | 中 | 三个弹窗「今天」的时区算法不一致 | 三处,见第四节 |
| P3-1 | 低 | 日期底价不过滤无效行,污染审计与商户通知 | merchant/index.vue:1454 |
| P3-2 | 低 | 日期区间无 366 天上限 | merchant/index.vue:568 |
| P3-3 | 低 | loadSettlementPrices 缺 .catch;重载前清空表格 | merchant/index.vue:1383-1388、:1408、:1458 |
| P3-4 | 低 | 无底价时「修改底价」按钮消失 | merchant/index.vue:397 |
| P3-5 | 低 | calendarMoney 绕过 fenToYuan | MerchantSalePriceDialog.vue:173 |
三、高危问题
P1-1:日期底价可选历史日期,整批回滚导致输入全部丢失
现象:打开「日期底价」弹窗,把日期区间往前调到过去的某天,勾选该行,保存 → 提交失败,管理员填写的底价和原因全部丢失,且没有任何提示告知是"因为多选了一个历史日期"。
原因:
AdminWeb/src/views/xl/merchant/index.vue:568 的 el-date-picker 没有配置 :disabled-date:
<el-date-picker v-model="settlementCalendarRange" type="daterange" ... />同时 :571-572 的表格也没有过滤历史行,勾选列对所有行开放。
后端 MerchantSettlementPriceService.listCalendar(:166 起)会把区间内每一天都补齐返回(缺行填 status = 1),因此历史日期在表格中必然出现且可勾选。
提交后命中 Api/ruoyi-system/.../MerchantSettlementPriceService.java:352:
java
if (date.toLocalDate().isBefore(LocalDate.now()))
throw new ServiceException("不能修改历史日期底价");而外层 updateCalendar(:219)带 @Transactional(rollbackFor = Exception.class) —— 任意一个历史日期导致整批回滚。
同页另两个弹窗都做对了,可直接参照:
MerchantSalePriceDialog.vue:162:editable: date >= today && row?.salePriceFen != null && ...MerchantInventoryDialog.vue:168-171:disabledRange()同时挡住历史日期与非当前月份
建议修法:给日期选择器加 :disabled-date,并在表格数据源过滤或用 selectable 关闭历史行的勾选框。参照 MerchantInventoryDialog 的 disabledRange 实现。
P1-2:「修改底价」完全没有上限校验
现象:管理员把新底价填得高于平台售价,保存后才收到红色报错,且要逐个对象试错才能摸清上限。
原因:
merchant/index.vue:1397-1411 的 saveSettlementPrice 只校验"整数分且大于 0":
js
if (!Number.isSafeInteger(settlementPriceForm.newPriceFen) || settlementPriceForm.newPriceFen <= 0)
return ElMessage.warning('请输入大于0且最多两位小数的有效底价')弹窗本体(:553-561)只显示「对象」和「当前底价」,连当前平台售价都没展示,管理员无从预判。
后端实际约束(MerchantSettlementPriceService.java):
| 类型 | 约束 | 位置 |
|---|---|---|
ROOM_TYPE | 底价 ≤ 平台售价;另需「不存在平台售价低于新底价的日期」 | :331、:333 |
MERCHANT_CAR | 底价 ≤ 车型日租售价 | :344 |
PRODUCT_ACTIVITY | 默认价校验 + 逐日校验未来所有日期售价 | validateActivityFloor :294 |
MANAGED_PRODUCT | 对该商品每一个非套餐活动都跑一遍 validateActivityFloor | :280 |
MANAGED_PRODUCT 最隐蔽:列表「默认平台售价」列会分别列出各活动售价,但改一处统一底价会被任意一个活动的售价卡住,管理员完全无法从界面预判。
建议修法:openSettlementPriceEdit 把 row.saleActivities / row.salePriceFen 一并带入表单,在弹窗内展示「当前平台售价」,前端先拦截再提交。
P1-3:乐观锁失败后无法自救
现象:打开弹窗后,若底价被其他人(或另一个标签页)改动,再点保存必然报「底价已变化,请刷新后重试」。弹窗不关闭、expectedOldPriceFen 不刷新,再点保存还是同一个错,只能手动关闭重开。
原因:
merchant/index.vue:1394 在打开弹窗时抓取期望值,此后不再更新:
js
Object.assign(settlementPriceForm, {
..., expectedOldPriceFen: Number(row.currentPriceFen), newPriceFen: Number(row.currentPriceFen), reason: ''
})后端 requireExpected(:477)与各 updateXxx 的 != 1 → "底价已变化,请刷新后重试" 都会触发该路径。日期底价弹窗同理,expectedOldPriceFen 取自勾选行的 currentPriceFen,同样是一次性快照。
对比:MerchantInventoryDialog 提供了 loadError + 「重试」按钮(:7-8、:196-198)和内联 saveError 提示(:30、:259-261),具备完整的失败恢复路径。这两个弹窗只依赖全局 toast,缺内联错误态。
建议修法:捕获 ServiceException 后就地刷新列表与弹窗期望值,或提供与库存弹窗一致的「刷新重试」入口。
四、中等问题
P2-1:空金额渲染成 ¥0.00 而非 -
AdminWeb/src/utils/money.js:2 对 null 返回 '0.00':
js
if (fen == null) return '0.00'后端确实会返回 null,且以下两处未做空值判断:
| 位置 | 字段 | 后端何时为 null |
|---|---|---|
merchant/index.vue:384「默认结算底价」列 | currentPriceFen | item() 构造时可空 |
merchant/index.vue:575 日期底价弹窗「平台售价」列 | salePriceFen | 商品活动 activityPriceFen == null;房型 room.salePriceFen == null 且无日历行 |
结果是"未配置"被显示成「¥0.00」,会严重误导管理员对毛利和底价的判断。
同文件 :392 反而写对了(row.salePriceFen == null ? '-' : ...),同一页面存在两种处理方式。
建议修法:抽取统一的 displayMoney(value) 空值工具,或至少在这两处补上空值分支。
P2-2:三个弹窗「今天」的时区算法不一致
| 位置 | 算法 | 与后端是否一致 |
|---|---|---|
merchantInventory.mjs:3-9 businessToday() | Asia/Shanghai | ✅ 一致 |
MerchantSalePriceDialog.vue:141、:169-171 localDate() | 浏览器本地时区 | ❌ |
merchant/index.vue:1412-1415 dateText() | 浏览器本地时区 | ❌ |
后端库存服务用 Asia/Shanghai,而售价 / 结算底价服务用 LocalDate.now()(JVM 默认时区)。
国内管理员一般无感,但时区偏后的机器上「今天」会被算成明天,于是能选中后端认作历史的那一天 —— 直接触发 P1-1 的整批回滚;is-today 高亮也会错位一天。
建议修法:三个弹窗统一复用 businessToday()(已存在于 merchantInventory.mjs,可直接抽到公共工具)。
五、低危问题
P3-1:日期底价不过滤无效行
merchant/index.vue:1454 把所有勾选行原样提交,即使 newPriceFen === expectedOldPriceFen。后端 updateCalendarRow 照写、照记 createItem,并触发 notificationService.enqueue 给商户推送通知卡片 —— 无变化的行会污染变更审计与商户通知。
MerchantSalePriceDialog.vue:262-264 和 merchantInventory.mjs:24 都已做 filter 去重,仅日期底价缺失。
P3-2:日期区间无 366 天上限
merchant/index.vue:568 的日期选择器无范围限制。超过 366 天时命中后端 validateRange(:481)「日期范围不能超过366天」,GET 直接失败 → 日历空白,管理员只看到一个 toast。前端也缺后端 updateCalendar:225 的「一次最多修改366天」提交上限。
P3-3:缺 .catch 与重载闪空表
merchant/index.vue:1383-1388的loadSettlementPrices只有.then().finally(),请求失败时产生 unhandled rejection(同文件loadProducts:1375是有.catch的)。:1408、:1458在loadSettlementPrices()前先执行settlementPriceRows.value = [],导致表格先空白再转圈,视觉上闪烁一次。
P3-4:无底价时「修改底价」按钮消失
merchant/index.vue:397 使用 v-if="row.currentPriceFen != null",导致 settlementPriceFen 为空的活动完全无法补设底价。而后端 saleEditable(MerchantSettlementPriceService.java:456)并不要求已有底价,会出现「能改售价、不能改底价」的矛盾行。需与产品确认这是否为预期。
P3-5:calendarMoney 绕过 fenToYuan
MerchantSalePriceDialog.vue:173 使用 Number(value) / 100,紧邻的 :172 的 money() 使用 fenToYuan。后者对字符串安全,前者不是,且会输出浮点形式(如 12.5 而非 12.50)。
六、测试盲区
AdminWeb/tests/merchant-settlement-price-contract.test.mjs:8-15 是纯存在性断言:
js
assert.match(view, /label="结算底价" name="settlementPrices"/)
assert.match(view, /expectedOldPriceFen/)
assert.match(view, /修改原因/)merchant-sale-price-contract.test.mjs、merchant-inventory.test.mjs 同类。这些测试只能证明"字符串出现过",本轮全部 10 个问题它们一个都测不出来(当前三者均通过)。
建议:参照已有的 merchantInventory.mjs 模式,把日期过滤、无效行过滤、空值渲染等纯逻辑抽出为可测的 .mjs,再补真实单元测试:
merchantInventory.test.mjs已示范了这种做法(对inventoryItems()做行为断言),价格弹窗应对齐。
七、建议修复顺序
- P1-1(历史日期)—— 加
disabled-date,改动最小、风险最低、数据损失风险最高。 - P1-2(底价上限)—— 展示平台售价并前置校验,需同步改
openSettlementPriceEdit的入参。 - P1-3(乐观锁恢复)—— 补内联错误态与刷新重试,与
MerchantInventoryDialog对齐。 - P2-1(空值渲染)—— 可与 P3-5 一并抽出
displayMoney工具函数。 - P2-2(时区)—— 统一到
businessToday(),需评估是否同时改后端的LocalDate.now()。 - P3 各项 —— 视排期合并处理。
- 同步更新 AdminWeb 商户详情商品价格调整操作说明,并在 商户详情价格修复与验证 追加本轮验证结果。
八、相关文档
- AdminWeb 商户详情商品价格调整操作说明 —— 运营操作手册
- 商户详情价格修复与验证 —— 上一轮修复记录
- 平台运营商品价格逻辑 —— 平台运营商品的统一定价规则
- 商户日期库存管理 —— 日期库存编辑规则
- 酒店房态与团购价格日历整体功能说明 —— 价格日历的数据模型