2 Commits

Author SHA1 Message Date
gxwebsoft d458115da9 fix(special-zone): 修复白名单弹窗checkbox未显示问题并添加专区小程序码功能
- 修复 UserSelectModal 弹窗中复选框不显示的根因,正确传入 selection-type="checkbox"
- 新增后台接口获取专区小程序码图片流,支持正式/体验/开发版环境
- 管理后台专区列表新增二维码按钮,弹窗展示对应专区小程序码
- 小程序端支持扫码参数 scene 解析,实现扫码直接进入专区页面
- 优化二维码弹窗组件,支持切换小程序版本并动态加载二维码
- 重新构建前端及后端服务,确保新功能生效和兼容原有业务逻辑
2026-08-17 02:39:52 +08:00
gxwebsoft f986efce8c fix(api): 修复专区白名单和商品移除假成功问题
- 调整 .env.development 中 VITE_API_URL 配置为正式接口地址
- 修复 ShopGoods.sectionIds 字段更新时 null 被忽略导致移除专区商品假成功问题
- 通过 UpdateWrapper 显式设置 section_ids 字段避免 MyBatis-Plus 全局更新策略影响
- 补充后端下单接口白名单强校验,防止绕过前端受限专区限制
- 澄清买家用户体系为 sys_user,确认白名单弹窗使用 pageUsers 数据源正确
- 删除白名单弹窗 isStaff 限制,白名单覆盖所有 sys_user 用户
- 记录专区白名单功能设计与实现细节,明确前后端联动机制
- 提示“专区管理”菜单入口需后端菜单表动态下发,确保后台导航可见
2026-08-17 02:00:05 +08:00
6 changed files with 191 additions and 4 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
VITE_APP_NAME=后台管理系统
VITE_API_URL=http://127.0.0.1:9200/api
#VITE_API_URL=https://shop-api.websoft.top/api
#VITE_API_URL=http://127.0.0.1:9200/api
VITE_API_URL=https://shop-api.websoft.top/api
VITE_CMS_API_URL=https://cms-api.websoft.top/api
+52
View File
@@ -0,0 +1,52 @@
# 2026-08-17 工作日志
## 专区商品「移除」假成功 bug 修复
- 现象:guilixu.websoft.top/special/zone 移除专区商品提示成功,刷新(重查库)后商品仍在。
- 根因:`ShopHomeSectionController.removeGoods` 用 `listByIds` + `updateBatchById` 改 `shop_goods.section_ids`;单专区商品移除后 section_ids 变 null,但 `ShopGoods.sectionIds` 无更新策略注解,MP 全局默认 `NOT_NULL` 会忽略 null 值 → 不生成 `SET section_ids=NULL` → 库字段保持原值(假成功)。多专区商品(变非 null)正常,故仅单专区场景坏。
- 修复:`addGoods`/`removeGoods` 改用 `UpdateWrapper.set("section_ids", newVal).eq("goods_id", id)` 显式更新(newVal 可为 null),绕过字段策略。文件:guilixu-java `ShopHomeSectionController.java`。
- 部署:需重新编译部署 guilixu-java(线上 shop-api)后端生效;前端无需改动。添加用同样模式统一修复。
- 验证:部署后用单专区商品移除测试,刷新不应再出现。
- 注:本机环境无 maven,未本地编译;改动为标准 MP 用法(IService.update(Wrapper) / UpdateWrapper.set 允许 null)。
## 专区白名单功能诊断(功能当前不可用)
- 设计意图:专区设为"受限专区"(restricted=1) 后,该专区商品仅白名单用户可用余额购买;小程序结算页 checkout.tsx 已调 `check-permission` 前端拦截。
- 致命错配:后台白名单弹窗 `UserSelectModal.vue` 用 `pageUsers`(@/api/system/user → sys_user 后台员工) 选用户;后端 `ShopHomeSectionController.checkPermission` 用 `getLoginUser().getUserId()`(= shop_user 小程序买家 userId)。两套用户体系 id 不一致 → 白名单里加的任何人(后台员工)对真实买家都匹配不上 → 受限专区对买家永远拦截/强制余额,白名单形同虚设。
- 次要隐患:后端创建订单接口未做白名单强校验,仅前端拦截,可被绕过。
- 修复方向:① 白名单弹窗数据源从 `pageUsers`(sys_user) 改为 `pageShopUser`(@/api/shop/shopUser → shop_user 买家),使存的 user_id 与校验 userId 同体系;② 后端下单接口加白名单强校验防绕过。后台已有 pageShopUser 可用。
## 专区白名单:用户体系澄清 + 后端强校验(用户 2026-08-17 01:50 指令)
- 用户澄清:本项目的买家(小程序用户)就是 `sys_user` 体系,`pageShopUser` 实际不用/返回空。故白名单弹窗用 `pageUsers`(sys_user) 是**正确**的,之前"用户体系错配"判断为误判。
- 白名单弹窗 `UserSelectModal.vue` 现状(8-16 已改):`pageUsers({ keywords, ... })`,**不传 isStaff**(搜索覆盖全部 sys_user)。源码无需再改。已 grep 确认 zone 目录下无残留 isStaff/pageShopUser。
- 后端补「受限专区白名单强校验」:`OrderBusinessService.java` 新增
- import:`ShopHomeSectionService`、`ShopHomeSectionUserService`、`QueryWrapper`、`java.util.Set/LinkedHashSet`;
- `@Resource` 注入 `homeSectionService`、`homeSectionUserService`;
- `createOrder` 在 `validateDeliveryRegionIfNeeded` 之后调用 `validateSectionPermissionIfNeeded(request, shopOrder, loginUser)`;
- 新方法逻辑:遍历 `request.goodsItems` → 取 `ShopGoods.sectionIds` → 找 restricted=1 的专区 → 校验 `loginUser.getUserId()` 是否在 `shop_home_section_user`(count>0),否则抛 `BusinessException("该商品属于受限专区,仅限指定白名单用户购买")`。
- 作用:前端 `check-permission` 的后端兜底,防止直接调 `/shop/shop-order` 下单绕过白名单。
- 部署:需重新编译部署 guilixu-java(shop-api)。前端无需改动。
- 注:本机无 maven,未本地编译;改动为标准 MP 用法(IService.count(QueryWrapper) / getById)。
- 待确认:用户 "2.帮我补" 指令只写了前半句,已按上文补后端强校验;若其本意是"补菜单记录"(专区管理入口,动态路由待插入),需另出 INSERT SQL(菜单表结构待确认)。
## 专区白名单弹窗 checkbox 不显示(第三次修复,根因确认)
- 现象:白名单弹窗数据正常(12 条用户),但表格无 checkbox 勾选列,用户无法选择。
- 已尝试修复(前两次均未生效):
1. 8-16:`reactive` → `computed` + `columnWidth: 48`(与 shopCoupon 对齐)
2. 同期:去掉 `isStaff: true`
- **真正根因**(读 `ele-pro-table` 源码 `node_modules/ele-admin-pro/es/ele-pro-table/index.js` 第 94-114 行确认):
- `ele-pro-table` 内部 `tableSelectionType` computed 有前置条件:必须传 `selection`、`current` 或 `selectionType="radio"` 三者之一,否则返回 `undefined`
- 返回 undefined → `tableRowSelection` 也返回 undefined → ant-design-vue Table 收到 `rowSelection=undefined` → **不渲染 checkbox 列**
- 我们的弹窗三个条件都不满足(没传 :selection / :current / selectionType),所以不管 row-selection 配置多正确都不会出勾选框
- **最终修复**:模板 `<ele-pro-table>` 加 `selection-type="checkbox"`(第 18 行),使源码判断走通:`props2.selectionType !== "radio"` 为 true(是 "checkbox")→ 不走 early return → 返回 "checkbox" → checkbox 列正常渲染。
- 改动文件:`src/views/special/zone/components/UserSelectModal.vue`(仅模板加 1 个 prop)
- 部署:重新构建前端即可(`npm run dev` 热更新或 `npm run build` 部署)。
## 专区小程序码(扫码直接进入专区)
- 需求:/special/zone 专区管理加「二维码」入口,生成该专区的小程序码,用户微信扫码直接进入小程序对应专区。
- 后端(guilixu-java `ShopHomeSectionController.java`):新增 `GET /api/shop/shop-home-section/{id}/qrcode`
- 调微信 `getwxacodeunlimit` 生成无限场景码;`scene=sectionId=xxx`,`page=pages/shop/section-detail`;按专区 `tenantId` 经 `WxMiniappAccessTokenService.getAccessToken(tenantId)` 取 token;prod 用 `release` 否则 `trial`(与现有 getOrderQRCodeUnlimited 一致)。
- 复用 Hutool `HttpRequest` 调微信,返回 PNG 流;微信出错时返回 JSON 并识别 `application/json`。
- 用户端小程序(guilixu-taro `pages/shop/section-detail.tsx`):解析 `useRouter().params.scene`(小程序码扫码透传的 `sectionId=xxx`),兼容原 `?sectionId=` 普通跳转。
- 管理后台(guilixu-admin `views/special/zone/index.vue` + `api/shop/shopZone`):列表操作列加「二维码」按钮 → 弹窗显示小程序码图片;新增 `getSectionQrcode(id)`(responseType blob → objectURL,遇 JSON 错误解析 message 并 reject)。
- 部署:① 重新编译部署 guilixu-java(shop-api)使新接口生效;② 重新构建前端 admin 与 taro 小程序(section-detail 需重新发布/体验版,因小程序码 page 指向已发布页面)。
- 注:本机无 maven,后端未本地编译;改动完全复用 WxLoginController 既有 getwxacodeunlimit 写法。
- 限制:getwxacodeunlimit 的 page 必须已存在于小程序(pages/shop/section-detail 已在 app.config 的 pages 列表);scene 仅限可见字符、≤32 位,`sectionId=xxx` 满足。
+15
View File
@@ -65,3 +65,18 @@
- 下单校验 `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)` 显式 set(newVal 可 null),绕过字段策略。
- 已修复点:`ShopHomeSectionController.addGoods`/`removeGoods`(2026-08-17)改用此模式。同项目其它"置空某字段"的更新都要警惕此坑。
+28
View File
@@ -182,3 +182,31 @@ export async function checkSectionPermission(sectionIds: number[]) {
}
return Promise.reject(new Error(res.data.message));
}
/**
* 获取专区小程序码(图片流),返回可用于 <img src> 的 objectURL
* @param id 专区ID
* @param envVersion release(正式版) / trial(体验版) / develop(开发版)
*/
export async function getSectionQrcode(
id: number,
envVersion: string = 'release'
): Promise<string> {
const res = await request.get(
MODULES_API_URL + '/shop/shop-home-section/' + id + '/qrcode',
{ responseType: 'blob', params: { envVersion } } as any
);
const blob = res.data as Blob;
// 微信/后端出错时会返回 JSON 而非图片,这里解析出错误信息
if (blob && blob.type && blob.type.indexOf('image') === -1) {
let msg = '生成小程序码失败';
try {
const json = JSON.parse(await blob.text());
if (json && json.error) msg = json.error;
} catch (e) {
/* 忽略解析失败 */
}
return Promise.reject(new Error(msg));
}
return window.URL.createObjectURL(blob);
}
@@ -15,6 +15,7 @@
:datasource="datasource"
:columns="columns"
:row-selection="rowSelection"
selection-type="checkbox"
:pagination="true"
:page-size="10"
>
+93 -2
View File
@@ -69,6 +69,8 @@
<a-divider type="vertical" />
<a @click="openGoods(record)">商品</a>
<a-divider type="vertical" />
<a @click="openQrcode(record)">二维码</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此专区吗?"
@confirm="remove(record)"
@@ -175,6 +177,39 @@
</a-table>
</a-spin>
</a-drawer>
<!-- 专区小程序码弹窗 -->
<a-modal
:visible="showQrcode"
:title="qrcodeTitle"
:footer="null"
:width="360"
:maskClosable="false"
@update:visible="closeQrcode"
>
<div style="text-align: center; padding: 8px 0 4px;">
<div style="margin-bottom: 10px;">
<a-radio-group v-model:value="qrcodeEnv" size="small" @change="onQrcodeEnvChange">
<a-radio-button value="release">正式版</a-radio-button>
<a-radio-button value="trial">体验版</a-radio-button>
<a-radio-button value="develop">开发版</a-radio-button>
</a-radio-group>
</div>
<a-spin :spinning="qrcodeLoading">
<img
v-if="qrcodeUrl"
:src="qrcodeUrl"
style="width: 240px; height: 240px; object-fit: contain; border: 1px solid #f0f0f0; border-radius: 8px;"
/>
<div v-else style="width: 240px; height: 240px; line-height: 240px; color: #999;">
加载中...
</div>
</a-spin>
<div style="margin-top: 12px; font-size: 13px; color: #888;">
微信扫一扫,直接进入该专区
</div>
</div>
</a-modal>
</div>
</template>
@@ -199,7 +234,8 @@
setSectionUsers,
listSectionGoods,
addSectionGoods,
removeSectionGoods
removeSectionGoods,
getSectionQrcode
} from '@/api/shop/shopZone';
import { pageShopGoods } from '@/api/shop/shopGoods';
import type {
@@ -231,6 +267,14 @@
const goodsSearchLoading = ref(false);
const candidateGoods = ref<any[]>([]);
// 专区小程序码弹窗
const showQrcode = ref(false);
const qrcodeLoading = ref(false);
const qrcodeUrl = ref('');
const qrcodeTitle = ref('专区小程序码');
const qrcodeEnv = ref('release'); // release(正式版) / trial(体验版) / develop(开发版)
const qrcodeSectionId = ref(0);
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageHomeSections({
@@ -300,7 +344,7 @@
{
title: '操作',
key: 'action',
width: 220,
width: 290,
fixed: 'right',
align: 'center',
hideInSetting: true
@@ -408,6 +452,53 @@
loadGoods(row.sectionId || 0);
};
/* 生成专区小程序码(按当前选择的版本) */
const genQrcode = (id: number) => {
qrcodeUrl.value = '';
qrcodeLoading.value = true;
getSectionQrcode(id, qrcodeEnv.value)
.then((url) => {
qrcodeUrl.value = url;
})
.catch((e) => {
message.error(e?.message || '生成小程序码失败');
showQrcode.value = false;
})
.finally(() => {
qrcodeLoading.value = false;
});
};
/* 打开专区小程序码弹窗 */
const openQrcode = (row: HomeSection) => {
const id = row.sectionId || 0;
qrcodeSectionId.value = id;
qrcodeTitle.value = '专区小程序码 - ' + (row.title || id);
if (qrcodeUrl.value) {
window.URL.revokeObjectURL(qrcodeUrl.value);
qrcodeUrl.value = '';
}
qrcodeLoading.value = true;
showQrcode.value = true;
genQrcode(id);
};
/* 切换小程序版本后重新生成 */
const onQrcodeEnvChange = () => {
if (qrcodeSectionId.value && showQrcode.value) {
genQrcode(qrcodeSectionId.value);
}
};
/* 关闭专区小程序码弹窗并回收资源 */
const closeQrcode = (v: boolean) => {
showQrcode.value = v;
if (!v && qrcodeUrl.value) {
window.URL.revokeObjectURL(qrcodeUrl.value);
qrcodeUrl.value = '';
}
};
const loadGoods = (sectionId: number) => {
goodsLoading.value = true;
listSectionGoods(sectionId, { page: goodsPage.value, limit: 10 })