Jelajahi Sumber

新增用户审核不通过原因

增加审核不通过状态、原因字段及平台端写入校验。`n禁止 App 资料更新自行修改审核状态,并补充迁移脚本、国际化与测试。
qmj 4 jam dari sekarang
induk
melakukan
3d877f03a8

+ 22 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -701,7 +701,6 @@ public class InfoUserController extends BaseController {
         user.setSex(infoUser.getSex());
         user.setStatus(infoUser.getStatus());
         user.setThiscode(infoUser.getThiscode());
-        user.setAuditStatus(infoUser.getAuditStatus());
         user.setAnnex(infoUser.getAnnex());
         user.setTelPhone(infoUser.getTelPhone());
         user.setDriversLicense(infoUser.getDriversLicense());
@@ -1160,6 +1159,9 @@ public class InfoUserController extends BaseController {
     @PostMapping
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult add(@RequestBody InfoUser infoUser) {
+        if (!normalizeAuditRejectReason(infoUser)) {
+            return error(MessageUtils.message("no.user.audit.reject.reason.required"));
+        }
         if (infoUserService.getinfouserName(infoUser.getUserName()) != null) {
             return error(MessageUtils.message("no.user.add") + infoUser.getUserName() + MessageUtils.message("no.user.login.exist"));
         } else if (infoUserService.getinfoPhone(infoUser.getPhone()) != null) {
@@ -1181,9 +1183,28 @@ public class InfoUserController extends BaseController {
     @Log(title = "用户信息", businessType = BusinessType.UPDATE)
     @PutMapping
     public AjaxResult edit(@RequestBody InfoUser infoUser) {
+        if (!normalizeAuditRejectReason(infoUser)) {
+            return error(MessageUtils.message("no.user.audit.reject.reason.required"));
+        }
         return toAjax(infoUserService.updateInfoUser(infoUser));
     }
 
+    private boolean normalizeAuditRejectReason(InfoUser infoUser) {
+        if (!"2".equals(infoUser.getAuditStatus())) {
+            infoUser.setAuditRejectReason(null);
+            return true;
+        }
+        if (infoUser.getAuditRejectReason() == null) {
+            return false;
+        }
+        String reason = infoUser.getAuditRejectReason().strip();
+        if (reason.isEmpty()) {
+            return false;
+        }
+        infoUser.setAuditRejectReason(reason);
+        return true;
+    }
+
     /**
      * 删除用户信息
      */

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

@@ -258,3 +258,4 @@ no.paytype.cash.merchant.only=Thanh toán tiền mặt chỉ dành cho đơn hà
 no.order.paytype.not.cash=Đơn hàng này không phải thanh toán tiền mặt
 no.order.cash.already.paid=Đơn hàng này đã được xác nhận thu tiền
 no.order.cash.cancelled=Đơn hàng đã bị hủy, không thể xác nhận thu tiền
+no.user.audit.reject.reason.required=审核不通过时必须填写审核不通过原因

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

@@ -261,3 +261,4 @@ no.paytype.cash.merchant.only=Cash payment is only available for merchant-create
 no.order.paytype.not.cash=This order is not a cash payment order
 no.order.cash.already.paid=Cash payment for this order has already been confirmed
 no.order.cash.cancelled=The order has been cancelled; cash payment cannot be confirmed
+no.user.audit.reject.reason.required=A rejection reason is required when the audit is rejected

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

@@ -261,3 +261,4 @@ no.paytype.cash.merchant.only=Thanh toán tiền mặt chỉ dành cho đơn hà
 no.order.paytype.not.cash=Đơn hàng này không phải thanh toán tiền mặt
 no.order.cash.already.paid=Đơn hàng này đã được xác nhận thu tiền
 no.order.cash.cancelled=Đơn hàng đã bị hủy, không thể xác nhận thu tiền
+no.user.audit.reject.reason.required=Phải nhập lý do khi kết quả xét duyệt không được thông qua

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

@@ -262,3 +262,4 @@ no.paytype.cash.merchant.only=现金支付仅支持商家创建订单
 no.order.paytype.not.cash=该订单不是现金支付订单
 no.order.cash.already.paid=该订单已确认收款
 no.order.cash.cancelled=订单已取消,不能确认收款
+no.user.audit.reject.reason.required=审核不通过时必须填写审核不通过原因

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

@@ -262,3 +262,4 @@ no.paytype.cash.merchant.only=現金支付僅支援商家建立訂單
 no.order.paytype.not.cash=該訂單不是現金支付訂單
 no.order.cash.already.paid=該訂單已確認收款
 no.order.cash.cancelled=訂單已取消,不能確認收款
+no.user.audit.reject.reason.required=審核不通過時必須填寫審核不通過原因

+ 93 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -7,22 +7,31 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.ruoyi.common.constant.HttpStatus;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IUserWalletService;
 import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.common.utils.spring.SpringUtils;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
-import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Locale;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -36,11 +45,27 @@ class InfoUserControllerTest {
     private InfoUserController controller;
     private IPosOrderService posOrderService;
     private IInfoUserService infoUserService;
+    private IUserWalletService userWalletService;
+    private static ConfigurableListableBeanFactory originalBeanFactory;
 
     @BeforeAll
     static void initializeTableMetadata() {
         TableInfoHelper.initTableInfo(
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.addMessage("no.user.audit.reject.reason.required", Locale.getDefault(),
+                "审核不通过时必须填写审核不通过原因");
+        messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
     }
 
     @BeforeEach
@@ -48,8 +73,10 @@ class InfoUserControllerTest {
         controller = new TestInfoUserController();
         posOrderService = mock(IPosOrderService.class);
         infoUserService = mock(IInfoUserService.class);
+        userWalletService = mock(IUserWalletService.class);
         ReflectionTestUtils.setField(controller, "posOrderService", posOrderService);
         ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "userWalletService", userWalletService);
     }
 
     @Test
@@ -89,6 +116,71 @@ class InfoUserControllerTest {
         assertTrue(sql.contains("sh_id"));
     }
 
+    @Test
+    void rejectsAuditRejectionWithoutReason() {
+        InfoUser user = new InfoUser();
+        user.setAuditStatus("2");
+        user.setAuditRejectReason(" ");
+
+        AjaxResult result = controller.edit(user);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        verify(infoUserService, never()).updateInfoUser(any(InfoUser.class));
+    }
+
+    @Test
+    void rejectsNewAuditRejectionWithoutReason() {
+        InfoUser user = new InfoUser();
+        user.setUserName("merchant");
+        user.setPhone("0912345678");
+        user.setAuditStatus("2");
+        user.setAuditRejectReason(" ");
+
+        AjaxResult result = controller.add(user);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        verify(infoUserService, never()).insertInfoUser(any(InfoUser.class));
+    }
+
+    @Test
+    void profileUpdateCannotChangeAuditStatus() {
+        InfoUser request = new InfoUser();
+        request.setAuditStatus("1");
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        controller.setuser(tokenFor(42L), request);
+
+        ArgumentCaptor<InfoUser> captor = ArgumentCaptor.forClass(InfoUser.class);
+        verify(infoUserService).saveOrUpdate(captor.capture());
+        assertNull(captor.getValue().getAuditStatus());
+    }
+
+    @Test
+    void trimsAndSavesAuditRejectionReason() {
+        InfoUser user = new InfoUser();
+        user.setAuditStatus("2");
+        user.setAuditRejectReason("  证件照片模糊  ");
+        when(infoUserService.updateInfoUser(user)).thenReturn(1);
+
+        controller.edit(user);
+
+        assertEquals("证件照片模糊", user.getAuditRejectReason());
+        verify(infoUserService).updateInfoUser(user);
+    }
+
+    @Test
+    void clearsOldReasonWhenAuditPasses() {
+        InfoUser user = new InfoUser();
+        user.setAuditStatus("1");
+        user.setAuditRejectReason("旧原因");
+        when(infoUserService.updateInfoUser(user)).thenReturn(1);
+
+        controller.edit(user);
+
+        assertNull(user.getAuditRejectReason());
+        verify(infoUserService).updateInfoUser(user);
+    }
+
     private String tokenFor(Long userId) {
         return JwtUtil.setToken(String.valueOf(userId), "test-user");
     }

+ 14 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java

@@ -104,8 +104,12 @@ public class InfoUser
     private String annex;
 
     /** 审核状态 */
-    @Excel(name = "审核状态", readConverterExp = "0=未审核,1=已审核")
+    @Excel(name = "审核状态", readConverterExp = "0=待审核,1=审核通过,2=审核不通过")
     private String auditStatus;
+
+    /** 审核不通过原因 */
+    @Excel(name = "审核不通过原因")
+    private String auditRejectReason;
     /** 司机审核状态 */
     @Excel(name = "司机审核状态", readConverterExp = "0=未审核,1=已审核")
     private String shijStatus;
@@ -387,6 +391,14 @@ public class InfoUser
         this.auditStatus = auditStatus;
     }
 
+    public String getAuditRejectReason() {
+        return auditRejectReason;
+    }
+
+    public void setAuditRejectReason(String auditRejectReason) {
+        this.auditRejectReason = auditRejectReason;
+    }
+
     public String getTelPhone() {
         return telPhone;
     }
@@ -600,6 +612,7 @@ public class InfoUser
             .append("commission", getCommission())
             .append("annex", getAnnex())
             .append("auditStatus", getAuditStatus())
+            .append("auditRejectReason", getAuditRejectReason())
             .append("telPhone", getTelPhone())
             .append("shijStatus", getShijStatus())
             .append("driversLicense", getDriversLicense())

+ 4 - 0
ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml

@@ -23,6 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="commission"    column="commission"    />
         <result property="annex"    column="annex"    />
         <result property="auditStatus"    column="audit_status"    />
+        <result property="auditRejectReason"    column="audit_reject_reason"    />
         <result property="telPhone"    column="tel_Phone"    />
         <result property="shijStatus"    column="shij_status"    />
         <result property="driversLicense"    column="drivers_license"    />
@@ -112,6 +113,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="commission != null">commission,</if>
             <if test="annex != null">annex,</if>
             <if test="auditStatus != null">audit_status,</if>
+            <if test="auditRejectReason != null">audit_reject_reason,</if>
             <if test="telPhone != null">tel_phone,</if>
             <if test="shijStatus != null">shij_status,</if>
             <if test="driversLicense != null">drivers_license,</if>
@@ -156,6 +158,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="commission != null">#{commission},</if>
             <if test="annex != null">#{annex},</if>
             <if test="auditStatus != null">#{auditStatus},</if>
+            <if test="auditRejectReason != null">#{auditRejectReason},</if>
             <if test="telPhone != null">#{telPhone},</if>
             <if test="shijStatus != null">#{shijStatus},</if>
             <if test="driversLicense != null">#{driversLicense},</if>
@@ -204,6 +207,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="commission != null">commission = #{commission},</if>
             <if test="annex != null">annex = #{annex},</if>
             <if test="auditStatus != null">audit_status = #{auditStatus},</if>
+            <if test="auditStatus != null">audit_reject_reason = #{auditRejectReason},</if>
             <if test="telPhone != null">tel_phone = #{telPhone},</if>
             <if test="shijStatus != null">shij_status = #{shijStatus},</if>
             <if test="driversLicense != null">drivers_license = #{driversLicense},</if>

+ 5 - 4
specs/021-user-audit-rejection/plan.md

@@ -4,7 +4,7 @@
 
 **Goal:** 平台管理员可以将商家或骑手标记为审核不通过并填写必填原因,后端持久化该原因并随用户信息返回。
 
-**Architecture:** 沿用 `InfoUser`、`PUT /infouser/user` 和现有 `sys_user_audit` 字典,不增加新接口或审核历史表。Controller 负责状态与原因的一致性校验,MyBatis 在审核状态发生更新时同步写入或清空原因,平台两个审核页负责条件展示和前端必填校验。
+**Architecture:** 沿用 `InfoUser`、平台用户新增/修改接口和现有 `sys_user_audit` 字典,不增加新接口或审核历史表。Controller 负责平台写入时状态与原因的一致性校验,并禁止 App 资料更新修改审核状态;MyBatis 在审核状态发生更新时同步写入或清空原因,平台两个审核页负责条件展示和前端必填校验。
 
 **Tech Stack:** Java 21、Spring Boot、MyBatis XML、MySQL、Vue 2、Element UI、vue-i18n、JUnit 5、Mockito。
 
@@ -32,12 +32,12 @@
 - Modify: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
 
 **Interfaces:**
-- Consumes: `PUT /infouser/user` 的现有 `InfoUser` JSON 请求。
+- Consumes: `POST /infouser/user`、`PUT /infouser/user` 的现有 `InfoUser` JSON 请求。
 - Produces: `InfoUser.getAuditRejectReason()/setAuditRejectReason(String)`;现有用户信息响应新增 `auditRejectReason`。
 
 - [ ] **Step 1: 写失败测试**
 
-在 `InfoUserControllerTest` 增加三条行为测试:状态 `2` 且原因只有空白时返回错误且不调用 Service;状态为 `2` 且原因有效时修剪后保存;状态为 `1` 时将旧原因清空后保存。
+在 `InfoUserControllerTest` 增加行为测试:新增或修改为状态 `2` 且原因只有空白时返回错误且不调用写入 Service;状态为 `2` 且原因有效时修剪后保存;状态为 `1` 时将旧原因清空后保存;App 资料更新不能改变审核状态
 
 ```java
 @Test
@@ -96,7 +96,8 @@ mvn -pl ruoyi-admin -am -Dtest=InfoUserControllerTest -Dsurefire.failIfNoSpecifi
 - `InfoUser` 新增 `String auditRejectReason` 及访问方法,并加入 `toString()`。
 - `InfoUserMapper.xml` 的 `resultMap`、insert 列和值补齐 `audit_reject_reason`。
 - update 中使用 `<if test="auditStatus != null">audit_reject_reason = #{auditRejectReason},</if>`,确保审核状态更新为非 `2` 时能写入 `NULL`。
-- `InfoUserController.edit` 在调用 Service 前完成状态校验、原因修剪和旧原因清空。
+- `InfoUserController.add/edit` 在调用 Service 前共用状态校验、原因修剪和旧原因清空。
+- `/infouser/user/setuser` 不再复制客户端的 `auditStatus`。
 - 四个后端资源文件新增 `no.user.audit.reject.reason.required`。
 
 - [ ] **Step 4: 运行定向测试确认 GREEN**

+ 2 - 1
specs/021-user-audit-rejection/spec.md

@@ -31,10 +31,11 @@
 
 ## 后端行为
 
-- 平台修改用户接口继续使用 `PUT /infouser/user`。
+- 平台新增和修改用户接口继续使用 `POST /infouser/user`、`PUT /infouser/user`,两者执行同一审核原因校验
 - 当 `auditStatus = "2"` 时,`auditRejectReason` 去除首尾空白后必须非空;否则返回国际化业务错误,不更新用户。
 - 当 `auditStatus` 不等于 `"2"` 时,后端主动将 `auditRejectReason` 设为 `null`,不能只依赖前端清理。
 - 用户列表、平台用户详情和 App 用户信息接口沿用现有 `InfoUser` 返回结构,新增返回 `auditRejectReason`。
+- App 资料更新接口 `/infouser/user/setuser` 不接受客户端提交的审核状态,防止用户自行改变审核结果。
 - Entity、MyBatis `resultMap`、查询、插入和更新映射全部补齐该字段。
 
 ## 数据库变更

+ 8 - 8
specs/021-user-audit-rejection/tasks.md

@@ -1,10 +1,10 @@
 # 用户审核不通过原因任务
 
-- [ ] T001 为审核不通过必填原因、原因修剪和非驳回状态清空原因编写后端测试。
-- [ ] T002 在 `InfoUser` 和 MyBatis Mapper 中增加 `auditRejectReason`。
-- [ ] T003 在平台用户修改接口增加审核状态与原因一致性校验,并补齐四语言错误信息。
-- [ ] T004 在商家审核页增加审核不通过状态显示、原因输入和条件必填。
-- [ ] T005 在骑手审核页增加审核不通过状态显示、原因输入和条件必填。
-- [ ] T006 补齐平台端简中、繁中、英文、越南语文案。
-- [ ] T007 在 `updatesql/sql.md` 追加 `TEXT` 字段和审核状态字典迁移 SQL。
-- [ ] T008 运行后端定向测试、后端模块构建、平台生产构建和最终差异检查
+- [x] T001 为审核不通过必填原因、原因修剪和非驳回状态清空原因编写后端测试。
+- [x] T002 在 `InfoUser` 和 MyBatis Mapper 中增加 `auditRejectReason`。
+- [x] T003 在平台用户新增、修改接口增加审核状态与原因一致性校验,禁止 App 资料更新修改审核状态,并补齐四语言错误信息。
+- [x] T004 在商家审核页增加审核不通过状态显示、原因输入和条件必填。
+- [x] T005 在骑手审核页增加审核不通过状态显示、原因输入和条件必填。
+- [x] T006 补齐平台端简中、繁中、英文、越南语文案。
+- [x] T007 在 `updatesql/sql.md` 追加 `TEXT` 字段和审核状态字典迁移 SQL。
+- [ ] T008 最终代码调整后的后端定向测试、模块构建和平台生产构建按用户要求暂不执行

+ 16 - 0
updatesql/sql.md

@@ -909,3 +909,19 @@ ALTER TABLE info_user
   ADD COLUMN rider_head_photo TEXT NULL COMMENT '骑手审核大头照(图片URL)' AFTER vehicle_type;
 ```
 
+## 2026-08-27 用户审核不通过原因
+
+```sql
+-- 仅记录迁移脚本,由开发者确认环境后手动执行;不直接执行数据库变更。
+ALTER TABLE info_user
+  ADD COLUMN audit_reject_reason TEXT NULL COMMENT '用户审核不通过原因' AFTER audit_status;
+
+-- 用户审核状态新增 2=审核不通过;重复执行时不会重复插入同一字典值。
+INSERT INTO sys_dict_data
+  (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
+SELECT 2, '审核不通过', '2', 'sys_user_audit', '', 'danger', 'N', '0', 'admin', NOW(), '用户审核不通过'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_user_audit' AND dict_value = '2'
+);
+```
+