Parcourir la source

新增平台商品审核接口并隔离商家鉴权逻辑

qmj il y a 6 jours
Parent
commit
991900b69c

+ 40 - 0
docs/platform-food-audit-api.md

@@ -0,0 +1,40 @@
+# 平台商品审核接口
+
+日期:2026-10-13。
+
+## 修复原因
+
+平台商品管理的审核开关曾调用 `PUT /chanting/food/changerecommend`。该方法为商家入口,要求商家 `token` 请求头、`@Auth` 和门店归属校验;平台使用 `Authorization: Bearer ...` 登录,因此出现缺少 `token` 的错误。不能把平台令牌伪装为商家令牌,也不能删除商家接口的身份/归属校验。
+
+## 平台专用方法
+
+`PUT /chanting/food/audit`
+
+- 登录与授权:现有平台 Authorization 机制,沿用 `chanting:food:edit` 权限;非匿名接口,不读取商家 token。
+- 请求 DTO 仅包含 `id`(正整数)与 `toExamine`(字符串 `"0"` 或 `"1"`)。`"1"` 为审核通过,`"0"` 为撤销审核;不改变商品本身的 `stackingUp` 上下架字段含义。
+- 服务端只构造 `id/toExamine` 的更新对象,使用现有 UPDATE,不能插入不存在的商品,不处理或删除规格关联,不能借审核改门店、价格、名称、推荐或图片。
+- 参数不合法返回本地化 `food.audit.invalid`;未更新到记录返回原 AjaxResult 失败格式。平台请求失败或取消确认时,审核开关恢复原值。
+- 操作沿用平台更新日志;商家 `/changerecommend` 完全不变。列表、详情和通用商品编辑接口不在本次调整范围内。
+
+```json
+{"id":9137,"toExamine":"1"}
+```
+
+成功响应为现有 AjaxResult `code=200`,业务失败 `code=500`;HTTP 200 本身不代表审核已成功。无数据库字段、结构或菜单权限新增,无需执行 SQL。
+
+## 验收
+
+1. 平台使用具备商品编辑权限的账号,切换审核通过/撤销,应请求 `/chanting/food/audit`,不再报缺少商家 token。
+2. 只有列表权限或未登录时不能执行审核;开关使用原商品编辑权限控制。
+3. 刷新列表核对审核结果;同时确认价格、门店归属、推荐、商品上架字段和规格均未变化。
+4. 非法 ID/状态、不存在商品、取消确认和请求失败不能产生错误成功提示;失败时开关恢复。
+5. 原商家接口仍要求商家登录与归属校验。
+
+自动测试使用模拟数据库访问,不连接实际数据库;部署后仍需验证真实平台登录、角色权限与数据库更新。
+
+## 本次本地验证
+
+- 已先复现原页面调用商家接口以及缺少平台审核方法的失败用例,再验证修复;商品审核新增 6 个 Java 用例和 2 个前端行为用例。
+- JDK 21 商品 Controller 与商家认证定向测试通过;`mvn -pl ruoyi-admin -am test -q` 全回归通过(admin/system 报告合计 1155 项、0 失败、0 错误、0 跳过)。`mvn -pl ruoyi-admin -am -DskipTests package -q` 模块打包通过,打包命令本身不运行测试。
+- 平台全部 Node 行为测试 27/27 通过;`food.js` ESLint 通过;staging 构建成功,保留既有两个 `.vue~` 备份文件和包体积警告,未修改无关文件。
+- 未执行:真实平台登录、无权限账号拦截、实际 MySQL 更新和部署后页面冒烟。上述 MockMvc 使用独立 Controller,权限注解为契约校验,不代表已跑完整 Spring Security/真实令牌集成。

+ 19 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.mendian.dto.FoodSearchOutput;
+import com.ruoyi.app.mendian.dto.FoodAuditRequest;
 import com.ruoyi.app.order.OrderInvoiceService;
 import com.ruoyi.app.utils.ImageCompressUtils;
 import com.ruoyi.common.annotation.Anonymous;
@@ -102,6 +103,24 @@ public class PosFoodController extends BaseController {
         return toAjax(posFoodService.saveOrUpdate(posFood));
     }
 
+    /**
+     * 平台商品审核使用平台 Authorization 与商品编辑权限,不复用商家 token/门店归属入口。
+     * 只更新审核字段,不走 saveOrUpdate 或规格处理,避免新增商品、改归属或清空规格关联。
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:edit')")
+    @Log(title = "商品审核", businessType = BusinessType.UPDATE)
+    @PutMapping("/audit")
+    public AjaxResult audit(@RequestBody FoodAuditRequest request) {
+        if (request == null || request.getId() == null || request.getId() <= 0
+                || !("0".equals(request.getToExamine()) || "1".equals(request.getToExamine()))) {
+            return error(MessageUtils.message("food.audit.invalid"));
+        }
+        PosFood update = new PosFood();
+        update.setId(request.getId());
+        update.setToExamine(request.getToExamine());
+        return toAjax(posFoodService.updatePosFood(update));
+    }
+
     /**
      * 添加或修改商品
      */

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/FoodAuditRequest.java

@@ -0,0 +1,11 @@
+package com.ruoyi.app.mendian.dto;
+
+import lombok.Data;
+
+/** 平台商品审核只接收商品 ID 与审核状态,不允许借审核修改商品内容或门店归属。 */
+@Data
+public class FoodAuditRequest {
+    private Long id;
+    /** 1=审核通过,0=撤销审核;与商家的商品上下架/推荐字段分开。 */
+    private String toExamine;
+}

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -1,4 +1,5 @@
 #错误消息
+food.audit.invalid=Thông tin xét duyệt sản phẩm không hợp lệ
 # 019 flash rider LINE Pay
 flash.line.rider.invalid=Tài khoản nhận tiền phải là tài xế đang hoạt động
 flash.line.accept.required=Chờ tài xế nhận đơn trước khi thanh toán

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -1,4 +1,5 @@
 #错误消息
+food.audit.invalid=Invalid product review parameters
 # 019 flash rider LINE Pay
 flash.line.rider.invalid=The payee must be an active rider account
 flash.line.accept.required=Wait for rider acceptance before paying

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -1,4 +1,5 @@
 # ข้อความแสดงข้อผิดพลาด
+food.audit.invalid=พารามิเตอร์การตรวจสอบสินค้าไม่ถูกต้อง
 # 019 flash rider LINE Pay
 flash.line.rider.invalid=บัญชีผู้รับเงินต้องเป็นไรเดอร์ที่ใช้งานอยู่
 flash.line.accept.required=รอไรเดอร์รับงานก่อนชำระเงิน

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -1,4 +1,5 @@
 #错误消息
+food.audit.invalid=商品审核参数不正确
 # 019 flash rider LINE Pay
 flash.line.rider.invalid=收款账号必须是正常且未删除的骑手
 flash.line.accept.required=请先等待骑手接单后再支付

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -1,4 +1,5 @@
 #错误消息
+food.audit.invalid=商品審核參數不正確
 # 019 flash rider LINE Pay
 flash.line.rider.invalid=收款帳號必須是正常且未刪除的騎手
 flash.line.accept.required=請先等待騎手接單後再付款

+ 128 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosFoodControllerAuditTest.java

@@ -0,0 +1,128 @@
+package com.ruoyi.app.mendian;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.service.IPosFoodService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.Auth;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.PutMapping;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+/** 平台审核独立于商家 token/门店归属,只更新审核状态,不能误走商品保存或规格处理。 */
+class PosFoodControllerAuditTest {
+    private IPosFoodService foods;
+    private MerchantStoreAccessService merchantAccess;
+    private MockMvc mvc;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setup() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        PosFoodController controller = new PosFoodController();
+        foods = mock(IPosFoodService.class);
+        merchantAccess = mock(MerchantStoreAccessService.class);
+        ReflectionTestUtils.setField(controller, "posFoodService", foods);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchantAccess);
+        mvc = MockMvcBuilders.standaloneSetup(controller).build();
+        when(foods.updatePosFood(any(PosFood.class))).thenReturn(1);
+    }
+
+    @AfterEach
+    void cleanup() { messages.close(); }
+
+    @Test
+    void platformAuditUsesExistingEditPermissionAndNeverMerchantAuth() {
+        Method audit = Arrays.stream(PosFoodController.class.getDeclaredMethods())
+                .filter(method -> method.isAnnotationPresent(PutMapping.class)
+                        && Arrays.asList(method.getAnnotation(PutMapping.class).value()).contains("/audit"))
+                .findFirst().orElse(null);
+        assertNotNull(audit, "platform audit endpoint must exist");
+        assertNotNull(audit.getAnnotation(PreAuthorize.class));
+        assertEquals("@ss.hasPermi('chanting:food:edit')", audit.getAnnotation(PreAuthorize.class).value());
+        assertNull(audit.getAnnotation(Anonymous.class));
+        assertNull(audit.getAnnotation(Auth.class));
+    }
+
+    @Test
+    void approveAndRevokeWorkWithoutMerchantTokenHeader() throws Exception {
+        for (String state : new String[] {"1", "0"}) {
+            mvc.perform(put("/chanting/food/audit").header("Authorization", "Bearer platform-test")
+                    .contentType(MediaType.APPLICATION_JSON).content("{\"id\":9137,\"toExamine\":\"" + state + "\"}"))
+                    .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200));
+        }
+        ArgumentCaptor<PosFood> update = ArgumentCaptor.forClass(PosFood.class);
+        verify(foods, times(2)).updatePosFood(update.capture());
+        assertEquals("1", update.getAllValues().get(0).getToExamine());
+        assertEquals("0", update.getAllValues().get(1).getToExamine());
+        verifyNoInteractions(merchantAccess);
+        verify(foods, never()).saveOrUpdate(any(PosFood.class));
+    }
+
+    @Test
+    void auditIgnoresInjectedContentAndNeverChangesStoreOrSpecs() throws Exception {
+        mvc.perform(put("/chanting/food/audit").contentType(MediaType.APPLICATION_JSON)
+                .content("{\"id\":9137,\"toExamine\":\"1\",\"mdid\":999,\"price\":1,\"name\":\"injected\",\"stackingUp\":\"0\",\"foodSpecs\":[]}"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200));
+        ArgumentCaptor<PosFood> update = ArgumentCaptor.forClass(PosFood.class);
+        verify(foods).updatePosFood(update.capture());
+        PosFood changed = update.getValue();
+        assertEquals(9137L, changed.getId());
+        assertEquals("1", changed.getToExamine());
+        assertNull(changed.getMdid());
+        assertNull(changed.getPrice());
+        assertNull(changed.getName());
+        assertNull(changed.getStackingUp());
+        assertNull(changed.getFoodSpecs());
+        assertNull(changed.getRecommend());
+    }
+
+    @Test
+    void invalidIdOrAuditStateIsRejectedBeforeWrite() throws Exception {
+        for (String payload : new String[] {"{}", "{\"id\":0,\"toExamine\":\"1\"}",
+                "{\"id\":-1,\"toExamine\":\"1\"}", "{\"id\":9137}",
+                "{\"id\":9137,\"toExamine\":\"2\"}", "{\"id\":9137,\"toExamine\":\"\"}"}) {
+            mvc.perform(put("/chanting/food/audit").contentType(MediaType.APPLICATION_JSON).content(payload))
+                    .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(500))
+                    .andExpect(jsonPath("$.msg").value("food.audit.invalid"));
+        }
+        verifyNoInteractions(foods);
+    }
+
+    @Test
+    void missingFoodDoesNotCreateProductOrReportSuccess() throws Exception {
+        when(foods.updatePosFood(any(PosFood.class))).thenReturn(0);
+        mvc.perform(put("/chanting/food/audit").contentType(MediaType.APPLICATION_JSON)
+                .content("{\"id\":9137,\"toExamine\":\"1\"}"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(500));
+        verify(foods, never()).saveOrUpdate(any(PosFood.class));
+    }
+
+    @Test
+    void originalMerchantEndpointStillRequiresMerchantToken() throws Exception {
+        mvc.perform(put("/chanting/food/changerecommend").header("Authorization", "Bearer platform-test")
+                .contentType(MediaType.APPLICATION_JSON).content("{\"id\":9137,\"toExamine\":\"1\"}"))
+                .andExpect(status().isBadRequest());
+        verifyNoInteractions(foods, merchantAccess);
+    }
+}