1-探索购物车合并与结算去重逻辑.md 17 KB

ai 自行的发出指令:

请在以下目录中做代码研究(只读,不要修改文件):

  • /Users/shishao/dnmp/www/front-end (前端 uni-app 多项目仓库,重点关注 mallApp 子项目)
  • /Users/shishao/dnmp/www/huahuibao (后端 Yii2 多应用仓库,重点关注 mallApp 对应的后端应用,可能是 app-mall)

需要回答的问题:

  1. 找到 mallApp/src/pages/home/cart.vue 完整文件内容。重点关注:

    • 购物车列表中商品的唯一标识逻辑(用什么字段判断“同一件商品”,是否用到 goodsId + specGoodsId,是否考虑秒杀 activityType / activityId / seckillId 等字段区分同一 specGoodsId 下的秒杀 vs 普通商品)。
    • 点击“去结算”时,是如何组装数据、通过什么方式(路由参数/本地存储/store)传递给 billing/affirmMix.vue 页面的。
    • 购物车中商品加入/合并(如加减数量、选中商品)时用什么 key 做 dedupe 或 grouping。
  2. 找到 mallApp/src/pages/billing/affirmMix.vue 完整文件内容。重点关注:

    • 页面拿到商品列表后,如何做“商品合并”(可能是把多条购物车记录,按 goodsId+specGoodsId 等聚合成同一个商品条目,尤其是涉及到数量累加、价格计算的地方)。
    • 找出具体做合并 (merge/groupBy/reduce) 的代码片段,列出用到的 key 字段。
    • 这个合并逻辑为什么会把用户描述的第2个(秒跌商品,specGoodsId=2120, activityType=seckill, price=3)和第3个商品(普通商品, specGoodsId=2120, price=16)当作同一个商品合并,请具体分析原因(多半是 groupBy key 只用了 goodsId+specGoodsId,没有把 activityType/activityId/秒杀标识纳入 key)。
    • 找出该合并逻辑相关的完整代码位置(文件路径+行号)。
  3. 在 mallApp 相关的 api 封装 (src/api/**) 中找到"提交订单"、"结算预览"相关接口,看看提交给后端的数据结构里,商品项是否带有 activityType/activityId/seckillId 等字段用以区分秒杀和普通商品。

  4. 在后端 huahuibao 仓库中,找到 app-mall (或类似目录) 中处理"订单结算/下单"逻辑的 Controller 及相关的业务类 (biz-//classes),重点关注:

    • 订单里商品的唯一性判断逻辑,是否使用 goodsId+specGoodsId,还是额外用了活动类型/活动id 来区分秒杀商品和普通商品。
    • 查找关键词如 "specGoodsId", "activityType", "seckill", "秒杀" 等,找到相关 Model/Form/Service 类的字段定义和处理逻辑。
    • 找出后端认为"同一商品"的判断字段有哪些,是否包含秒杀相关标识。
  5. 搜索整个 mallApp 目录下(不限于这两个文件)是否有其他地方用到类似的商品合并/去重逻辑(例如 utils 里的公共函数),这些函数被哪些页面复用。

请把找到的关键代码片段(带文件路径和行号)、以及你的分析总结,完整返回给我。这非常重要,请尽量详细并给出具体代码内容,不要只给结论。

Ai 回答输出

以下是只读研究结果。核心结论先说:购物车加购能区分秒杀/普通,但结算页合并 key 漏了 activityType,因此同 goodsId + specGoodsId 的秒杀与普通会被合成一行。


1. cart.vue:唯一标识、去结算、加减合并

文件:/Users/shishao/dnmp/www/front-end/mallApp/src/pages/home/cart.vue(约 1197 行;逻辑在 mixin cgProduct

1.1 商品唯一标识(真正逻辑在 mixin)

购物车页本身不写 dedupe key,而是:

    getItemKey(item) {
      return this.getSelectRowKey(item)
    },
    findCartRowIndex(list, item) {
      if (!item || item.id === undefined || item.id === null) {
        return -1
      }
      return list.findIndex((el) => this._selectRowMatches(el, item))

cgProduct.js 里两套逻辑不一致

A. getSelectRowKey(勾选 map / 结算合并用)——不含 activityType

		/** 购物车行唯一键:花束用 id+property+specGoodsId,花材用 id(+classId) */
		getSelectRowKey(item) {
			if (!item || item.id === undefined || item.id === null) return '';
			const property = this.getItemProperty(item);
			if (property === 0) {
				return `0_${item.id}_${item.specGoodsId || 0}`;
			}
			if (this._shouldMergeSelectByProductId()) {
				return `1_${item.id}`;
			}
			return `1_${item.id}_${item.classId || 0}`;
		},

花束 key = 0_{goodsId}_{specGoodsId}没有 activityType / activityId / seckillId

B. _selectRowMatches(加购/删除/加减定位用)——花束会比 activityType

		_selectRowMatches(element, item) {
			if (!element || !item) return false;
			const eleProperty = this.getItemProperty(element);
			const itemProperty = this.getItemProperty(item);
			if (eleProperty !== itemProperty) return false;
			if (eleProperty === 0) {
				// 秒杀与普通购买同商品同规格也必须分行,避免不同计价规则的购物车行被合并
				if ((element.activityType || '') !== (item.activityType || '')) return false;
				return String(element.id) === String(item.id)
					&& String(element.specGoodsId || 0) === String(item.specGoodsId || 0);
			}
			if (this._shouldMergeSelectByProductId()) {
				return String(element.id) === String(item.id);
			}
			return element.id == item.id && element.classId == item.classId;
		},

加购时写入 activityType: 'seckill'

			// 秒杀商品:打上活动标记与原价,用于购物车/结算页展示划线价;
			// 实际下单价格由后端独立核对活动配置核价,此处价格仅用于前端展示
			if (goods.activityType === 'seckill') {
				row.activityType = 'seckill';
				row.activityEndTime = Number(goods.activityEndTime) || 0;
				row.originPrice = Number(goods.originPrice) || 0;
			}

结论(购物车侧):

场景 判断字段 是否区分秒杀 vs 普通
加购合并 / 加减 / 删除 property + id + specGoodsId + activityType 是,可两行并存
勾选 checkedMap key 0_id_specGoodsId(无 activityType) ,同规格秒杀/普通共用勾选态
仓库中未发现 activityId / seckillId 参与购物车行身份

展示上会看 activityType === 'seckill' 打「秒杀」标(cart.vue 第 51 行)。

1.2 「去结算」如何传给 affirmMix

    goCheckout() {
      const list = this.checkedCheckoutList
      ...
      const checkoutUrl = '/pages/billing/affirmMix'
      ...
      const navigateAffirm = () => {
        this.limitBuyWarnList = []
        this.setSelectInfoByType({ type: this.pageType, info: list })
        this.setLimitBuyInfoByType({ type: this.pageType, info: [] })
        const realHdId = this.hdId || uni.getStorageSync('hdId') || 0
        this.$util.pageTo({
          url: checkoutUrl,
          query: { account, hdId: realHdId }
        })
      }

传递方式:

  1. VuexsetSelectInfoByType({ type: 'cg', info: 勾选列表 }) —— 商品明细走 store,不是 URL。
  2. 路由 query:仅 accounthdId
  3. 限购时还会 setLimitBuyInfoByType;本地缓存 key 形如 selectListcg_hd_{account}persistCartToStorage / rememberProduct)。

2. affirmMix.vue:合并逻辑与误合并原因

文件:/Users/shishao/dnmp/www/front-end/mallApp/src/pages/billing/affirmMix.vue

2.1 合并代码(根因位置)

onShow

	onShow() {
		this.syncShopContext();
		this._syncMergedSelectListIfNeeded();
		const selectList = this.$util.copyObject(this.selectList);
		const mergedMap = {};
		const list = [];
		for (let i = 0; i < selectList.length; i++) {
			const item = selectList[i];
			const key = this.getSelectRowKey(item) || String(item.id);
			if (mergedMap[key]) {
				mergedMap[key].bigCount = Number(mergedMap[key].bigCount || 0) + Number(item.bigCount || 0);
				mergedMap[key].smallCount = Number(mergedMap[key].smallCount || 0) + Number(item.smallCount || 0);
			} else {
				mergedMap[key] = this.$util.copyObject(item);
				list.push(mergedMap[key]);
			}
		}
		this.list = list;

公共合并(会先改 Vuex):

		_mergeDuplicateSelectRows(list) {
			...
				const key = this.getSelectRowKey(ele) || String(ele.id);
				if (!map.has(key)) {
					...
				} else {
					const prev = map.get(key);
					prev.bigCount = Number(prev.bigCount) + (Number(ele.bigCount) || 0);
					prev.smallCount = Number(prev.smallCount) + (Number(ele.smallCount) || 0);
				}
			...
		},
		_syncMergedSelectListIfNeeded() {
			if (!this._shouldMergeSelectByProductId()) return;
			...
			const merged = this._mergeDuplicateSelectRows(list);
			if (merged.length !== list.length) {
				this.setSelectInfoByType({ type: this.pageType, info: merged });
			}
		},

2.2 为什么秒杀(specGoodsId=2120, price=3) 和普通(specGoodsId=2120, price=16) 会被合成一件

假设两行:

  • 秒杀:id=主商品, specGoodsId=2120, activityType='seckill', price=3
  • 普通:id=主商品, specGoodsId=2120, activityType='' , price=16

两者 getSelectRowKey 都是:

0_{主商品id}_2120

合并时:

  1. _syncMergedSelectListIfNeeded() 先按该 key 合并进 Vuex;
  2. onShow 再用同一 key 累加 bigCount / smallCount
  3. 只保留先出现那一行的其它字段priceactivityType 等),后一行数量加进去。

因此会出现:

  • 数量相加;
  • 价格/秒杀标只剩第一条的;
  • UI 只剩一行。

根因确认:合并 key 只用了 property + id + specGoodsId,没有把 activityType(更没有 activityId)纳入。
加购侧 _selectRowMatches 已意识到要分行,但 getSelectRowKey / 结算合并未对齐。

2.3 合并相关完整位置

位置 路径 行号
结算页合并循环 mallApp/src/pages/billing/affirmMix.vue 588–603
进入页先同步合并 Vuex 同上 + cgProduct._syncMergedSelectListIfNeeded affirmMix 588;mixin 298–305
key 定义 mallApp/src/mixins/cgProduct.js 262–272
Map 合并实现 mallApp/src/mixins/cgProduct.js 273–296
加购可区分 activityType mallApp/src/mixins/cgProduct.js 307–317, 475–485

3. mallApp API:提交订单 / 结算预览

/Users/shishao/dnmp/www/front-end/mallApp/src/api/order/index.js

export const createOrder = data => {
	return https.post('/order/create-order', data)
}

/** *
 * 花束+花材合并结算下单(不影响 buy-item / create-order)
 */
export const createMixOrder = data => {
	return https.post('/order/create-mix-order', data)
}
  • 混合结算提交createMixOrderPOST /order/create-mix-order(affirmMix 使用)
  • 纯花材buyItem/order/buy-item(affirmGhs)
  • 立即购买花束createOrder/order/create-order
  • 未发现单独的「结算预览」接口;affirmMix 金额在前端算,提交时后端再核价。相关辅助:getLimitBuyInfoorderRelateallDeliveryQuotes(跑腿报价)

affirmMix 提交结构(花束是否带活动字段)

			const product = (this.list || []).map((ele) => {
				if (this.isBouquetItem(ele)) {
					const saleId = Number(ele.specGoodsId) > 0 ? Number(ele.specGoodsId) : Number(ele.id);
					const row = {
						productId: saleId,
						goodsId: Number(ele.goodsId || ele.id),
						specGoodsId: Number(ele.specGoodsId) || 0,
						num: Number(ele.bigCount) || 1,
						unitType: 0,
						property: 0
					};
					// 秒杀/团购打上活动标记,后端据此独立核对活动库存/限购/价格后再计价
					if (ele.activityType === "seckill" || ele.activityType === "groupBuy") {
						row.activityType = ele.activityType;
					}
					return row;
				}
				...
				return { productId: ele.id, num, itemId: ele.itemId, unitType, property: 1 };
			});

要点:

  • 花束可带 activityType: 'seckill' | 'groupBuy'
  • 没有 activityId / seckillId
  • 花材行不带活动字段
  • 若前端已误合并,只会提交一行;是否带 activityType 取决于合并后保留的是哪一行

4. 后端 app-mall:下单唯一性与秒杀

入口:/Users/shishao/dnmp/www/huahuibao/app-mall/controllers/OrderController.phpactionCreateMixOrder(约 958 行起)

4.1 处理方式

product JSON 逐行处理,没有goodsId+specGoodsId 再做跨行去重。

花束分支(约 1149–1255):

  • goodsId / specGoodsId / productId 解析销售单元 saleGoodsId
  • 仅当 activityType === 'seckill' 时走秒杀:

                    // 秒杀商品:不信任前端传的价格,独立回查 Redis 秒杀配置核实活动状态/库存/限购后再核价
                    $seckillRow = null;
                    if (strval($element['activityType'] ?? '') === 'seckill') {
                        $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
                        ...
                    }
                    ...
                    if ($seckillRow) {
                        $unitPrice = floatval($seckillRow['price'] ?? 0);
                        ...
                    } else {
                        $ret = \bizHd\goods\classes\GoodsClass::getFinalPrice(...);
                        ...
                    }
                    if ($seckillRow) {
                        HomePageModuleClass::reserveSeckillPurchase($mainId, $saleGoodsId, $hdCustomId, $goodsNum);
                    }
    

秒杀配置查找:HomePageModuleClass::getSeckillActiveRow($mainId, $goodsId) —— 按 销售商品 id(规格 id) 匹配,无单独 activityId:

    public static function getSeckillActiveRow($mainId, $goodsId)
    {
        $data = self::getSeckill($mainId, true);
        ...
            if (intval($item['goodsId'] ?? 0) === $goodsId) {
                return $item;
            }

4.2 后端「同一商品」字段

维度 字段
花材行 productId(+ property=1)
花束行 goodsId + specGoodsId/productIdsaleGoodsId
秒杀 vs 普通 activityType === 'seckill'(请求行级);无 activityId
秒杀库存/限购键 mainId + saleGoodsId(规格商品 id)

后端本身允许同规格两行(一行带 seckill、一行不带);当前问题主要在前端合并丢掉一行后再提交

单品立即购买 create-order 同样用顶层 activityType(约 610 行),逻辑一致。


5. mallApp 其它合并/去重复用

函数/逻辑 文件 被谁用
getSelectRowKey / _mergeDuplicateSelectRows / _syncMergedSelectListIfNeeded / _selectRowMatches src/mixins/cgProduct.js 混入 cart、affirmMix、affirmGhs、商品详情加购等
affirmMix onShow mergedMap pages/billing/affirmMix.vue 588–603 仅混合结算
affirmGhs onShow pages/billing/affirmGhs.vue 367–381 只用 item.id 合并,更粗,主要面向花材
addBouquetToCart mixin goods/detail.vueonAddCartFromPopup

未发现独立 utils 公共 groupBy(goodsId, specGoodsId);合并集中在 cgProduct mixin + 两个结算页 onShow

仓库内没有 activityId / seckillId 作为购物车/结算行 key;活动标识基本是字符串 activityType: 'seckill'


总览(问题链路)

加购 (_selectRowMatches 含 activityType)
  → 购物车可显示两行(秒杀 ¥3 + 普通 ¥16)

勾选 key = getSelectRowKey(无 activityType)
  → 同规格两行共享 checkedMap

去结算 → Vuex selectList + 路由 account/hdId

affirmMix onShow
  → _syncMergedSelectListIfNeeded + mergedMap
  → key = 0_id_2120(无 activityType)
  → 两行合成一行,数量累加,价格/活动标取第一行

提交 createMixOrder
  → 只剩一行 product;activityType 可能错/丢

修复方向(仅分析,未改代码):getSelectRowKey(及 _mergeDuplicateSelectRows / affirmMix 合并)与 _selectRowMatches 对齐,花束 key 至少加入 activityType(例如 0_${id}_${specGoodsId}_${activityType||''}),勾选 map 同步调整。