Skip to content

商户详情价格逻辑审查 ​

更新:已结合当前代码核验并实施修复,结果见 商户详情价格修复与验证。以下保留原始审查记录;原文“保存失败输入全部丢失”不成立,同价日期也不能一律视为无效行。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 未声明 disabled prop,但 Vue 会把未声明的属性透传到根组件 el-input 的同名 disabled prop(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 绕过 fenToYuanMerchantSalePriceDialog.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「默认结算底价」列currentPriceFenitem() 构造时可空
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() 做行为断言),价格弹窗应对齐。

七、建议修复顺序 ​

  1. P1-1(历史日期)—— 加 disabled-date,改动最小、风险最低、数据损失风险最高。
  2. P1-2(底价上限)—— 展示平台售价并前置校验,需同步改 openSettlementPriceEdit 的入参。
  3. P1-3(乐观锁恢复)—— 补内联错误态与刷新重试,与 MerchantInventoryDialog 对齐。
  4. P2-1(空值渲染)—— 可与 P3-5 一并抽出 displayMoney 工具函数。
  5. P2-2(时区)—— 统一到 businessToday(),需评估是否同时改后端的 LocalDate.now()。
  6. P3 各项 —— 视排期合并处理。
  7. 同步更新 AdminWeb 商户详情商品价格调整操作说明,并在 商户详情价格修复与验证 追加本轮验证结果。

八、相关文档 ​

携岛 XieDao · 内部文档