feat(shopZone): 新增专区折扣系数功能及购物车链路支持

- HomeSection 模型增加 discountRate 字段,默认 1.00 表示不打折
- zoneEdit 组件新增折扣系数输入框,支持设置 0.01~1 之间的折扣率
- 后端 guilixu-java 增加专区折扣逻辑,成交价=市场价×折扣系数,保证后端价格计算安全
- 小程序 guilixu-taro 频道详情和商品详情支持专区折扣展示和传参
- 购物车新增 sectionId 字段,支持专区折扣价计算,完善购物车加购与价格计算逻辑
- 提供对应迁移 SQL 脚本更新数据库结构(专区折扣率与购物车 section_id)
- 修复历史订单商品支付状态,补偿专区销量统计数据
- MEMORY 文档补充详细开发与设计说明,明确双后端接口调度和业务逻辑划分
This commit is contained in:
2026-08-18 10:53:15 +08:00
parent 5943f1536d
commit 8d8080c7bf
7 changed files with 106 additions and 90 deletions
+40
View File
@@ -0,0 +1,40 @@
# 2026-08-18 工作日志
## 专区商城价 = 市场价 × 折扣系数 功能(已实现)
客户要求:专区的商城价显示为市场价的 0.78(并按此价成交)。已与用户确认:展示+成交价都按系数、每专区单独配(默认1.00)、基数=市场价 salePrice。
### 后端 guilixu-java(仅此,商城走 guilixu-java
- `ShopHomeSection` + `ShopHomeSectionParam` 增加 `discountRate`(BigDecimal, 默认1.00)。
- 迁移 SQL`guilixu-admin/zone-discount-rate-10606.sql``ALTER TABLE shop_home_section ADD discount_rate DECIMAL(10,2) NOT NULL DEFAULT 1.00`)。需在生产库执行。
- `OrderCreateRequest.OrderGoodsItem` 增加 `sectionId`(每商品项携带专区ID)。
- `OrderBusinessService` **两处**价格解析(①`validateAndCalculateTotal` 算总额 ②`createOrder` 落库订单商品价)都加了专区折扣逻辑:若 `item.sectionId` 命中且商品 `section_ids` 含它、且专区 `discountRate≠1`,则 `actualPrice = goods.getSalePrice() × rate`(保留两位小数,HALF_UP)。后端不信任前端传价,按 sectionId 重算,防篡改。
### 后台 guilixu-admin
- `src/api/shop/shopZone/model/index.ts``HomeSection` 增加 `discountRate?: number`
- `src/views/special/zone/components/zoneEdit.vue`:表单加「折扣系数」输入框(a-input-number0.01~1,默认1,提示"专区商城价=市场价×系数"),表单默认值 `discountRate: 1`
### 小程序 guilixu-taro(仅「专区→立即购买」链路打通)
- `src/api/shop/shopHomeSection/index.ts` 增加 `getHomeSection(id)`
- `src/api/shop/shopHomeSection/model/index.ts``HomeSection` 增加 `discountRate?`
- `section-detail.tsx`:进入时 `getHomeSection` 取折扣系数并展示(价格=市场价×系数,划线价显示市场价),跳转详情带 `sectionId`+`discountRate`
- `product-detail.tsx`:读 `sectionId`/`discountRate`,按系数展示;立即购买时把系数写入 `buy_now` 存储并带参跳结算。
- `src/api/shop/shopOrder/model/index.ts``OrderGoodsItem` 增加 `sectionId?`
- `checkout.tsx`:增加 `getItemDisplayPrice`/`getItemSectionId`,按系数估价并随下单提交 `sectionId`
### ⚠️ 已知限制
- **购物车链路不支持专区折扣**:服务端 `shop_cart` 表无 `sectionId` 字段,从专区「加入购物车」再结算会走原价(仅「立即购买」享受折扣)。若要购物车也生效,需给 `shop_cart` 表加 `section_id` 并在下单时透传——后续需求。
- 未执行迁移 SQL、未重新部署后端、未构建发布前端,需按环境操作。
### 验证
- 前端 `tsc --noEmit` 因环境配置(taro 缺 @tarojs/taro 类型、admin 大量既存错误)未完整跑通,但本次改动文件未产生任何新报错。
- 后端 Maven 不在本环境,未编译;改动为标准 Java,已逐行核对。
## 购物车链路打通专区折扣(补充实现)
用户要求把"购物车也支持专区折扣"补上。在「立即购买」已实现基础上,打通购物车链路:
- **后端 guilixu-java**`ShopCart` 实体加 `sectionId`(真实列)+`discountRate`(exist=false 关联字段)`ShopCartMapper.xml` 的 selectSql 增加 `LEFT JOIN shop_home_section hs ON a.section_id = hs.section_id` 并选 `hs.discount_rate AS discount_rate`section_id 为 NULL 时不影响普通商品);`ShopCartServiceImpl.addToCart` 在"已存在累加"分支若本次新传 sectionId 非空则刷新 `existCart.sectionId`(让专区加购覆盖普通加购的 null)。迁移SQL:`zone-cart-section-id-10606.sql``ALTER TABLE shop_cart ADD section_id INT NULL`)。
- **小程序 guilixu-taro**`CartContext.addItem` 增可选 `sectionId` 参数并透传给加购接口;`refresh` 映射带 `sectionId`/`discountRate``CartItem` 接口加两字段;`calcPrice``discountRate≠1` 时用 `salePrice×rate``section-detail`/`product-detail` 加购调用传 `sectionId``cart.tsx` 单价展示用折扣价;`checkout.tsx``getItemDisplayPrice` 折扣分支改读顶层 `salePrice`(购物车项 product 为空,否则算不出折扣价)。
- 下单成交价仍由 `OrderBusinessService``sectionId` 重算,购物车项带 sectionId 后自动生效,无需改下单逻辑。
- 验证:taro `tsc --noEmit` 对本次改动 5 个文件零新报错。
+9 -90
View File
@@ -1,93 +1,12 @@
# 项目长期记忆 # 项目长期记忆
## 项目结构 ## 专区折扣系数功能(2026-08-18 新增)
- 需求:专区商城价 = 市场价(salePrice) × discountRate(每专区可配,默认 1.00;本专区用 0.78)。
### 关键项目路径 - **后端 guilixu-java**`ShopHomeSection.discountRate` 字段 + 迁移SQL(`guilixu-admin/zone-discount-rate-10606.sql`)`OrderCreateRequest.OrderGoodsItem.sectionId``OrderBusinessService` 两处价格解析按 sectionId 重算成交价= salePrice×rate(后端算,不信任前端价)。
- **管理后台**`/Users/gxwebsoft/VUE/guilixu-admin`Vue3 + Ant Design Vue - **后台 guilixu-admin**`zoneEdit.vue` 有「折扣系数」输入;`shopZone/model` HomeSection 有 `discountRate`
- **用户端小程序**`/Users/gxwebsoft/VUE/guilixu-taro`Taro + React - **小程序 guilixu-taro**`section-detail``product-detail``checkout` 透传 sectionId+discountRate,「立即购买」与「购物车」两条链路均按系数生效(购物车补充见下)。
- **商家端小程序**`/Users/gxwebsoft/VUE/xinlong-shop-taro`Taro + React - **购物车链路补充(同日)**`shop_cart` 表加 `section_id` 列(迁移SQL见 `zone-cart-section-id-10606.sql`);`ShopCart` 实体加 `sectionId`(真实列)+`discountRate`(exist=false)、`ShopCartMapper.xml` LEFT JOIN 专区取 `discount_rate``addToCart` 在"已存在累加"分支若新传 sectionId 非空则刷新 `existCart.sectionId`(专区加购覆盖普通加购的 null);`CartContext.addItem` 透传 sectionId、`calcPrice``cart.tsx` 按系数展示;`checkout.tsx``getItemDisplayPrice` 折扣分支改读顶层 `salePrice`(购物车项 product 为空)。两条链路现在都生效。
- 生效前置:执行迁移SQL、重新部署 guilixu-java、构建发布 taro。
### ⚠️ 跨项目教训(重要)
- 商家端发货/门店/订单相关功能在 **xinlong-shop-taro**,不要改到 guilixu-taro
- 用户端订单详情等功能在 **guilixu-taro**
- 跨项目改动前,先用 grep/glob 在多个候选项目里确认"哪个项目有该功能源码",再动手
- 已发生两次误改 guilixu-taro 的教训(useClerk 全局上下文、订单详情 deliveryType 修复)
## 业务约定
### deliveryType 配送方式枚举
- `0` = 快递配送
- `1` = 无需发货 / 自提
- `2` = 商家送货
来源:管理后台 `src/views/shop/shopOrder/components/deliveryModal.vue`
## Java 后端架构(重要)
### 双后端 / MODULES_API_URL 模式(2026-07-25 分析结论)
- **mp-java**`/Users/gxwebsoft/JAVA/mp-java`)= 独立的「modules 服务」,用 `modules` 库;cms(235类)、shop 等业务模块都在它这里,持续高频开发。
- **guilixu-java**`/Users/gxwebsoft/JAVA/guilixu-java`= 桂礼序主后端,用 `db_guilixu` 库;是 mp-java 的「子集分叉」。
- **guilixu-admin 前端已预设双基地址**:`SERVER_API_URL`(主后端) + `MODULES_API_URL`(localStorage ApiUrl / VITE_API_URL)。
- ⚠️ **运行时实际指向以 `localStorage ApiUrl` 为准**2026-08-06 用户纠正):**guilixu-admin 的「商城」接口实际调 `https://guilixu-api.websoft.top`= guilixu-java**,并非 mp-java。**判断"改哪个后端才生效"不能默认 MODULES_API_URL=mp-java,必须查 ApiUrl 运行时配置。** cms 接口另走 `VITE_CMS_API_URL`=cms-api.websoft.topmp-java)。
- **结论(仅限 cms 模块):guilixu-java 里的 cms 包(152类)是 mp-java cms 的旧分叉子集、且前端并不调它(前端调 modules 服务),属重复/死代码,应删除而非复制或扩展。**
- 复制「整个 cms 到 guilixu-java」不可行:mp-java 完整 cms 的 CmsApp/CmsWebsiteService 依赖 `project` 模块(guilixu-java 没有),会编译失败。
- 注意:mp-java 与 guilixu-java 是独立 git 仓库(`git.websoft.top/gxwebsoft/mp-java.git``guilixu-java.git`),各自独立部署、独立库。
### ⚠️ 双后端改动同步规则(2026-08-05 修正)
- **cms 模块**改动:仅 mp-javaguilixu-java 的 cms 是死代码)。
- **shop 等共享业务模块**(如 `ShopGoodsController` / Service / Mapper / task):**mp-java 与 guilixu-java 都有活代码且结构一致(同 `com.gxwebsoft.shop` 包)**,用户确认改动要**两端同步**(例:2026-08-05 shopGoods `/data` 统计优化,两端各改 6 处)。
- 改动前先 grep 两端确认文件是否存在,避免只改一处导致另一端行为不一致。
### ⚠️ 运费模板不发货地区需求(2026-08-06 结论,已按用户纠正修正)
- 需求:运费模板支持「不发货地区(黑名单)」,ShopExpressTemplateDetail 加 regionMode/regionIds 字段 + DeliveryRegionChecker 下单校验。
- **后端(guilixu-java)已完整实现**entity/param/迁移SQL/DeliveryRegionChecker/RegionCodeResolver/OrderBusinessService 接入(第 87 行调用 validateDeliveryRegionIfNeeded)均就位。
- **关键事实(用户 2026-08-06 18:09 纠正):guilixu-admin 的「商城」接口实际调 `https://guilixu-api.websoft.top`= guilixu-java**。前端 shopExpressTemplateDetail 用默认 `@/utils/request`baseURL = MODULES_API_URL = localStorage ApiUrl || VITE_API_URL;本部署的 ApiUrl 即 guilixu-api.websoft.top/api。故 guilixu-java 是生效后端,其实现正确生效,不是死代码。
- **结论:本功能(admin 配置)无需改 mp-java**(本部署 商城不走 mp-java)。
- **端到端链路已确认打通(2026-08-12)**:用户端 `guilixu-taro``BaseUrl = https://guilixu-api.websoft.top/api`config/env.js 全环境),`TenantId='10606'`,下单 `POST /shop/shop-order` 即走 guilixu-java(db_guilixu);且单租户下买家 tenantId=10606(微信支付证书也依赖此值),故 `DeliveryRegionChecker``tenant_id=10606` 能命中黑名单。✅ 功能完整闭环。
- **✅ 后台 UI 已支持「不发货黑名单」模式(2026-08-12 完成)**`shopExpressTemplateDetailEdit.vue` 已加「配送模式」三态 radio(0全国/1指定配送/2指定不发货);mode=2 复用 `RegionMultiSelect` 选地区,保存写 `region_mode=2` + `region_ids`(城市码逗号串);回显按 `region_mode` 初始化。列表页 `index.vue` 的「配送地区」列按 `region_mode` 显示「不发货:xxx / 配送:xxx / 默认全国」,并把城市码聚合为省名。后端比对是编码集合 contains,故后台存城市码与 SQL 存省份码都正确拦截、互不冲突。
- 仍可用 SQL 直接 INSERT 黑名单明细(region_mode=2, region_ids=偏远省码, status=0, tenant_id=10606)作为备选,无需经过 UI。
- 接口方案文档:`docs/运费模板-不发货地区-接口方案.md`
### shopGoods status 字段(上架/下架)
- `0` = 已上架 / 上架
- `1` = 待上架 / 下架
- `2` = 待审核,`3` = 审核不通过
- 编辑表单「状态」radio 绑定 `form.status`0=上架、1=下架。
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
### 运费模板配送地区多选(2026-08-11 已实现前端)
- 需求:配送地区选择从「省+市单选级联」升级为「省+市两级多选穿梭弹窗」(仿客户截图双栏:左候选/右已选)。
- 前端组件:`src/components/RegionMultiSelect/index.vue`(树形可展开,省级"选择"=选该省全部市,市级可单独选;@update:value 城市编码数组)。
- **直接对接后端已有的 `regionMode`(0=全国/1=指定配送白名单/2=不发货黑名单) + `regionIds`(逗号分隔城市编码串) 机制,无需后端改动**。城市编码即 `regions-data.json` 的 city value(6位),与 `regionIds` 完全一致。
- 数据字段:model 用 `regionMode` / `regionIds`(已有字段,非新建);旧 `provinceId`/`cityId` 仅作回显兼容。
- 列表多值展示:`index.vue` customRender 遍历 `regionIds` 反查 regionMap,超 22 字截断 + tooltip。
- 下单校验 `DeliveryRegionChecker``regionIds` 直接生效(regionMode=2 为不发货黑名单)。
- ⚠️ 教训:第一版误用 `provinceIds`/`cityIds` 字段名,与后端 `regionMode`/`regionIds` 不匹配,MyBatis-Plus 自动忽略未知字段导致"没存"。改字段名对接即可,不必新增后端字段。
### 专区白名单用户体系(2026-08-17 用户确认澄清)
- **结论:本项目买家(小程序用户)就是 `sys_user` 体系,不存在用户体系错配。** 之前怀疑"pageUsers(sys_user) vs 小程序买家(shop_user) 错配"是误判——`pageShopUser`(@/api/shop/shopUser) 在本项目实际不用/返回为空。
- 白名单弹窗 `UserSelectModal.vue``pageUsers`(@/api/system/user`sys_user`) 选用户是**正确**的;存到 `shop_home_section_user.user_id` 与下单校验 `checkPermission` 用的 `getLoginUser().getUserId()` 同体系,能正确匹配。
- 8-16 已去掉 `isStaff:true` 限制,白名单搜索覆盖全部 sys_user 用户(不局限于后台员工)。
- ⚠️ 不要再把白名单弹窗数据源改成 pageShopUser(用户明确说 pageShopUser 没用)。
- **后端下单白名单强校验(2026-08-17 已补)**`OrderBusinessService.createOrder` 新增 `validateSectionPermissionIfNeeded(request, shopOrder, loginUser)`,遍历订单商品的 `section_ids` 找受限专区(restricted=1),校验下单用户是否在 `shop_home_section_user` 白名单,不在则抛 "该商品属于受限专区,仅限指定白名单用户购买"。这是前端 `check-permission` 的后端兜底,防直接调下单接口绕过。
- 前端 guilixu-taro `pages/shop/checkout.tsx` 已调用 `check-permission` 做前端拦截(受限专区强制余额/block)。
- **当前状态:专区白名单功能完整可用**(前端拦截 + 后端兜底)。另:专区管理菜单入口由后端菜单表下发(动态路由),若后台左侧导航看不到"专区管理",仍需在后端菜单表补一条记录(component=special/zone, tenant_id=10606)。
### ⚠️ ShopGoods.sectionIds 字段更新陷阱(MyBatis-Plus 字段策略)
- `ShopGoods.sectionIds``@TableField(updateStrategy)`,走全局默认 `NOT_NULL`application.yml 未配 `update-strategy`)。
-`updateById`/`updateBatchById``section_ids` 设为 `null`(清空所属专区)时,MP **忽略该 null 字段、不生成 SET**,表现"更新成功但库未变"(假成功)。
- **正确做法**:清空/置空场景改用 `UpdateWrapper.set("section_ids", newVal)` 显式 setnewVal 可 null),绕过字段策略。
- 已修复点:`ShopHomeSectionController.addGoods`/`removeGoods`(2026-08-17)改用此模式。同项目其它"置空某字段"的更新都要警惕此坑。
### 专区销量统计(2026-08-17 新增)
- 需求:专区管理页 `/special/zone` 统计专区销量(件数 + 销售额),用户确认 **方案B(订单真实成交聚合)+ 列表汇总列 + 独立统计弹窗**
- **后端(guilixu-java,仅此,无需 mp-java**
- `ShopHomeSection` 实体加 `salesNum`/`salesAmount``@TableField(exist=false)` 统计字段,`pageRel` 分页后批量填充)。
- 新增 VO`SectionSalesSummaryVO`(列表汇总)、`ShopSectionGoodsRankVO`(商品排行项)、`SectionSalesStatsVO`(含 `List<ShopSectionGoodsRankVO> goodsRank`)。
- `ShopHomeSectionMapper.xml` 新增两条 JOIN 聚合 SQL`selectSectionSalesSummary`(批量,`FIND_IN_SET(section_id, g.section_ids)` 关联商品 → 聚合 `shop_order_goods`)、`selectSectionGoodsRank`(单专区 TOP50 排行)。
- `ShopHomeSectionController` 新增 `GET /shop/shop-home-section/{id}/stats?start=&end=`
- **统计口径**`shop_order_goods.pay_status=1`(已付款) 且 `order_status NOT IN (2已取消, 6退款成功)`;按 `tenant_id` 隔离;时间范围对应 `shop_order_goods.create_time`yyyy-MM-dd HH:mm:ss)。
- ⚠️ 订单商品表**无 sectionId**,专区销量=该专区 `section_ids` 关联商品的销量汇总;商品若属多个专区会被各专区重复计入(业务口径已知)。
- **前端(guilixu-admin**`src/api/shop/shopZone``getSectionSalesStats` + 类型;`zone/index.vue` 列表加「销量件数」「销售额」两列 + 操作列「销量」按钮;新建 `src/views/special/zone/components/SectionSalesStatsModal.vue`(时间范围 今日/近7天/近30天/自定义 + 汇总卡片 + 商品销量排行)。前端 `vite build` 已通过。
## 双后端事实(沿用)
- 商城接口实际调 `https://guilixu-api.websoft.top`(= guilixu-java, db_guilixu)cms 走 mp-java。改商城相关先确认运行时 ApiUrl。详见历史 MEMORY。
+22
View File
@@ -0,0 +1,22 @@
-- ============================================================
-- 修复历史已支付订单的订单商品支付状态(专区销量统计数据补偿)
-- 适用后端:guilixu-java(本部署商城实际后端,db_guilixu)
-- 租户:10606
-- 背景:下单时 shop_order_goods.pay_status 默认 0,支付成功链路此前只更新
-- 主订单 shop_order.pay_status,不会同步订单商品表。专区销量统计按
-- shop_order_goods.pay_status=1 聚合,导致历史已支付订单从未被统计。
-- 作用:把主订单已支付(pay_status=1)且订单商品仍为未支付(pay_status=0)的记录,
-- 同步为已支付,并把 order_status 对齐主订单(退款订单 order_status=6 会被统计口径排除)。
-- 执行前请先备份,并在低峰期执行;可先 SELECT 预览影响行数:
-- SELECT COUNT(*) FROM shop_order_goods og
-- JOIN shop_order o ON og.order_id = o.order_id AND o.tenant_id = 10606
-- WHERE og.tenant_id = 10606 AND o.pay_status = 1 AND og.pay_status = 0;
-- ============================================================
UPDATE shop_order_goods og
JOIN shop_order o ON og.order_id = o.order_id AND o.tenant_id = 10606
SET og.pay_status = 1,
og.order_status = o.order_status
WHERE og.tenant_id = 10606
AND o.pay_status = 1
AND og.pay_status = 0;
+2
View File
@@ -20,6 +20,8 @@ export interface HomeSection {
styleType?: number; styleType?: number;
// 是否受限专区 0否 1是 // 是否受限专区 0否 1是
restricted?: number; restricted?: number;
// 专区商城价折扣系数, 成交价=市场价×系数, 默认1.00不打折
discountRate?: number;
// 排序号(数字越小越靠前) // 排序号(数字越小越靠前)
sortNumber?: number; sortNumber?: number;
// 状态 0正常 1禁用 // 状态 0正常 1禁用
@@ -75,6 +75,20 @@
un-checked-children="普通专区" un-checked-children="普通专区"
/> />
</a-form-item> </a-form-item>
<a-form-item label="折扣系数" name="discountRate">
<a-input-number
:min="0.01"
:max="1"
:step="0.01"
:precision="2"
style="width: 100%"
placeholder="默认1.00表示不打折"
v-model:value="form.discountRate"
/>
<div class="text-xs text-gray-400 mt-1">
专区商城价 = 市场价 × 系数 0.78 则专区价显示为市场价的 0.78
</div>
</a-form-item>
<a-form-item label="排序号" name="sortNumber"> <a-form-item label="排序号" name="sortNumber">
<a-input-number <a-input-number
:min="0" :min="0"
@@ -152,6 +166,7 @@
categoryIds: undefined, categoryIds: undefined,
styleType: 0, styleType: 0,
restricted: 0, restricted: 0,
discountRate: 1,
sortNumber: 100, sortNumber: 100,
status: 0, status: 0,
startTime: undefined, startTime: undefined,
+8
View File
@@ -0,0 +1,8 @@
-- 专区折扣:购物车记录所属专区ID(用于购物车链路按专区折扣系数结算)
-- 作用库:guilixu-java 的 db_guilixu(商城实际库)
ALTER TABLE shop_cart
ADD COLUMN section_id INT NULL COMMENT '专区ID(从专区加入购物车时记录, 用于专区折扣价计算, 普通加购为null)'
AFTER goods_id;
-- 验证
-- SELECT id, goods_id, section_id FROM shop_cart WHERE section_id IS NOT NULL LIMIT 10;
+10
View File
@@ -0,0 +1,10 @@
-- 专区商城价折扣系数:成交价 = 市场价(salePrice) × discount_rate
-- 作用于 guilixu-java 的 db_guilixu 库(shop_home_section 表)
-- 租户 10606 生效环境执行;默认 1.00 表示不打折,本专区按需求填 0.78
ALTER TABLE shop_home_section
ADD COLUMN discount_rate DECIMAL(10, 2) NOT NULL DEFAULT 1.00
COMMENT '专区商城价折扣系数, 成交价=市场价×系数, 1.00表示不打折';
-- 如需将「某专区」直接设为 0.78 折(替换 <section_id>):
-- UPDATE shop_home_section SET discount_rate = 0.78 WHERE section_id = <section_id> AND tenant_id = 10606;