Ver Fonte

fix(activity): 修复审核报“审核操作无效”,审核通过时补充五维权重

问题一:审核中心 ActivityAudit.vue 提交 action=publish/reject,
而 /api/admin/activity/audit 只接受 approved/rejected,导致 400「审核操作无效」。
- 后端归一化审核动作,兼容 publish/reject/approved/rejected;
- 前端改为提交标准 approved/rejected。

问题二:活动创建端已移除维度,审核时需要管理员补充五维权重。
- 审核抽屉新增 DimensionWeightPicker(打开时回填已保存权重,无则默认均分);
- 审核通过时五维必须齐全且合计 100%,否则前端拦截;
- /activity/audit 新增 dimensionWeights 参数,后端校验后在审核事务内
  写入 dimension_weights 表及 activities.dimension_weights JSON 列;
- 通过分支补记 auditorId / auditedAt。

测试:
- 后端 ActivityAuditTest 7 项(动作兼容、权重校验、事务保存、驳回不要求权重);
- 前端 ActivityAudit.spec.js 5 项(动作映射、权重校验、权重回填)。
Xiaogang Liao há 4 dias atrás
pai
commit
2bd3c6e29d

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.service.ActivityAdminService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
 
@@ -75,8 +76,11 @@ public class AdminActivityController {
         String action = (String) params.get("action");
         String rejectReason = (String) params.get("rejectReason");
         String dimensionCode = (String) params.get("dimensionCode");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> dimensionWeights =
+                (List<Map<String, Object>>) params.get("dimensionWeights");
         if (id == null || action == null) return Result.error("id和action不能为空");
-        activityAdminService.auditActivity(id, action, rejectReason, adminId, dimensionCode);
+        activityAdminService.auditActivity(id, action, rejectReason, adminId, dimensionCode, dimensionWeights);
         return Result.success("已审核");
     }
 

+ 95 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityAdminService.java

@@ -4,13 +4,17 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.DimensionWeight;
 import com.etotem.cfc.mapper.ActivityMapper;
 import org.springframework.stereotype.Service;
 
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
@@ -26,6 +30,9 @@ public class ActivityAdminService {
     @Resource
     private ActivityMapper activityMapper;
 
+    @Resource
+    private DimensionWeightService dimensionWeightService;
+
     /**
      * 分页查询活动列表(包含草稿)
      * 支持按 status / dimensionCode / auditStatus 过滤
@@ -136,7 +143,8 @@ public class ActivityAdminService {
      * @param params action: "approved" | "rejected", id: Long, dimensionCode: String(approved 时必填), rejectReason: String
      */
     @Transactional
-    public void auditActivity(Long id, String action, String auditReason, Long adminId, String dimensionCode) {
+    public void auditActivity(Long id, String action, String auditReason, Long adminId, String dimensionCode,
+                              List<Map<String, Object>> dimensionWeights) {
         Activity activity = activityMapper.selectById(id);
         if (activity == null) {
             throw new IllegalStateException("活动不存在");
@@ -144,16 +152,21 @@ public class ActivityAdminService {
         if (!"pending".equals(activity.getStatus())) {
             throw new IllegalStateException("仅待审核状态可审核");
         }
-        if (!"approved".equals(action) && !"rejected".equals(action)) {
+        String normalizedAction = normalizeAuditAction(action);
+        if (normalizedAction == null) {
             throw new IllegalStateException("审核操作无效");
         }
-        if ("approved".equals(action)) {
+        List<DimensionWeight> weights = null;
+        if ("approved".equals(normalizedAction)) {
             if (dimensionCode == null || dimensionCode.trim().isEmpty()) {
                 throw new IllegalStateException("审核通过时必须选择所属维度");
             }
+            weights = parseAndValidateDimensionWeights(dimensionWeights);
             activity.setStatus("published");
             activity.setAuditStatus("approved");
             activity.setDimensionCode(dimensionCode.trim());
+            activity.setAuditorId(adminId);
+            activity.setAuditedAt(new Date());
         } else {
             activity.setStatus("rejected");
             activity.setAuditStatus("rejected");
@@ -162,6 +175,85 @@ public class ActivityAdminService {
             activity.setAuditedAt(new Date());
         }
         activityMapper.updateById(activity);
+        if (weights != null) {
+            // 同时写入 dimension_weights 表与 activities.dimension_weights JSON 列
+            dimensionWeightService.saveWeights("activity", id, weights);
+        }
+    }
+
+    private static final List<String> REQUIRED_DIMENSIONS =
+            Arrays.asList("body", "mind", "wisdom", "action", "wealth");
+
+    /**
+     * 校验并转换审核时填写的五维权重:五个维度必须齐全,合计必须为 100%。
+     */
+    private List<DimensionWeight> parseAndValidateDimensionWeights(List<Map<String, Object>> rawWeights) {
+        if (rawWeights == null || rawWeights.isEmpty()) {
+            throw new IllegalStateException("审核通过时必须填写五维权重");
+        }
+        Map<String, Integer> weightByDimension = new HashMap<>();
+        for (Map<String, Object> item : rawWeights) {
+            if (item == null || item.get("dimension") == null || item.get("weight") == null) {
+                continue;
+            }
+            String dimension = item.get("dimension").toString().trim().toLowerCase();
+            if (!REQUIRED_DIMENSIONS.contains(dimension)) {
+                continue;
+            }
+            int weight;
+            Object weightObj = item.get("weight");
+            if (weightObj instanceof Number) {
+                weight = ((Number) weightObj).intValue();
+            } else {
+                try {
+                    weight = Integer.parseInt(weightObj.toString().trim());
+                } catch (NumberFormatException e) {
+                    throw new IllegalStateException("五维权重格式不正确");
+                }
+            }
+            if (weight < 0 || weight > 100) {
+                throw new IllegalStateException("五维权重必须在0-100之间");
+            }
+            weightByDimension.put(dimension, weight);
+        }
+
+        List<DimensionWeight> weights = new ArrayList<>();
+        int sum = 0;
+        for (String dimension : REQUIRED_DIMENSIONS) {
+            Integer weight = weightByDimension.get(dimension);
+            if (weight == null) {
+                throw new IllegalStateException("五维权重必须包含身/心/智/行/富五个维度");
+            }
+            sum += weight;
+            DimensionWeight w = new DimensionWeight();
+            w.setDimension(dimension);
+            w.setWeight(weight);
+            w.setEnabled(weight > 0);
+            weights.add(w);
+        }
+        if (sum != 100) {
+            throw new IllegalStateException("五维权重合计必须为100%");
+        }
+        return weights;
+    }
+
+    /**
+     * 归一化审核动作:兼容审核中心历史提交的 publish/reject 与标准 approved/rejected。
+     *
+     * @return approved / rejected;无法识别时返回 null
+     */
+    private String normalizeAuditAction(String action) {
+        if (action == null) {
+            return null;
+        }
+        String normalized = action.trim().toLowerCase();
+        if ("approved".equals(normalized) || "approve".equals(normalized) || "publish".equals(normalized)) {
+            return "approved";
+        }
+        if ("rejected".equals(normalized) || "reject".equals(normalized)) {
+            return "rejected";
+        }
+        return null;
     }
 
     @Transactional

+ 169 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityAuditTest.java

@@ -0,0 +1,169 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.DimensionWeight;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.service.ActivityAdminService;
+import com.etotem.cfc.service.DimensionWeightService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * 审核中心活动审核:动作兼容 publish/reject,审核通过时必须补充五维权重。
+ */
+class ActivityAuditTest {
+
+    private ActivityAdminService service;
+    private ActivityMapper activityMapper;
+    private DimensionWeightService dimensionWeightService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ActivityAdminService();
+        activityMapper = mock(ActivityMapper.class);
+        dimensionWeightService = mock(DimensionWeightService.class);
+        set("activityMapper", activityMapper);
+        set("dimensionWeightService", dimensionWeightService);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = ActivityAdminService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private Activity pending() {
+        Activity a = new Activity();
+        a.setId(9L);
+        a.setTitle("测试活动");
+        a.setStatus("pending");
+        a.setAuditStatus("pending");
+        return a;
+    }
+
+    private List<Map<String, Object>> weights(int body, int mind, int wisdom, int action, int wealth) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        String[] dims = {"body", "mind", "wisdom", "action", "wealth"};
+        int[] vals = {body, mind, wisdom, action, wealth};
+        for (int i = 0; i < dims.length; i++) {
+            Map<String, Object> m = new HashMap<>();
+            m.put("dimension", dims[i]);
+            m.put("weight", vals[i]);
+            m.put("enabled", vals[i] > 0);
+            list.add(m);
+        }
+        return list;
+    }
+
+    @Test
+    void audit_审核中心提交publish动作_按通过处理() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        service.auditActivity(9L, "publish", null, 1L, "body", weights(20, 20, 20, 20, 20));
+
+        assertEquals("published", activity.getStatus());
+        assertEquals("approved", activity.getAuditStatus());
+        assertEquals("body", activity.getDimensionCode());
+        assertEquals(Long.valueOf(1L), activity.getAuditorId());
+        assertNotNull(activity.getAuditedAt());
+    }
+
+    @Test
+    void audit_通过时保存五维权重() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        service.auditActivity(9L, "approved", null, 1L, "middle", weights(40, 15, 15, 15, 15));
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List<DimensionWeight>> captor = ArgumentCaptor.forClass(List.class);
+        verify(dimensionWeightService).saveWeights(eq("activity"), eq(9L), captor.capture());
+
+        List<DimensionWeight> saved = captor.getValue();
+        assertEquals(5, saved.size());
+        assertEquals(100, saved.stream().mapToInt(DimensionWeight::getWeight).sum());
+        DimensionWeight body = saved.stream().filter(w -> "body".equals(w.getDimension())).findFirst().orElse(null);
+        assertNotNull(body);
+        assertEquals(40, body.getWeight());
+        assertTrue(body.getEnabled());
+    }
+
+    @Test
+    void audit_通过时缺少五维权重_报错且不落库() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.auditActivity(9L, "approved", null, 1L, "body", null));
+
+        assertTrue(e.getMessage().contains("五维权重"), e.getMessage());
+        verify(activityMapper, never()).updateById(any(Activity.class));
+        verify(dimensionWeightService, never()).saveWeights(any(), any(), any());
+    }
+
+    @Test
+    void audit_通过时权重合计不为100_报错() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.auditActivity(9L, "approved", null, 1L, "body", weights(20, 20, 20, 20, 19)));
+
+        assertTrue(e.getMessage().contains("100"), e.getMessage());
+        verify(activityMapper, never()).updateById(any(Activity.class));
+    }
+
+    @Test
+    void audit_通过时维度不完整_报错() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+
+        List<Map<String, Object>> onlyFour = weights(25, 25, 25, 25, 0);
+        onlyFour.removeIf(m -> "wealth".equals(m.get("dimension")));
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.auditActivity(9L, "approved", null, 1L, "body", onlyFour));
+
+        assertTrue(e.getMessage().contains("五个维度"), e.getMessage());
+    }
+
+    @Test
+    void audit_驳回不要求五维权重() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        service.auditActivity(9L, "reject", "材料不全", 1L, null, null);
+
+        assertEquals("rejected", activity.getStatus());
+        assertEquals("rejected", activity.getAuditStatus());
+        assertEquals("材料不全", activity.getAuditReason());
+        verify(dimensionWeightService, never()).saveWeights(any(), any(), any());
+    }
+
+    @Test
+    void audit_无效动作_报审核操作无效() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.auditActivity(9L, "unknown", null, 1L, "body", weights(20, 20, 20, 20, 20)));
+
+        assertTrue(e.getMessage().contains("审核操作无效"), e.getMessage());
+    }
+}

+ 47 - 7
cfc-web/src/views/admin/review/ActivityAudit.vue

@@ -69,6 +69,10 @@
             <el-option label="富 · 物质精神" value="wealth" />
           </el-select>
         </el-form-item>
+        <el-form-item label="五维权重" v-if="currentRow && currentRow.auditStatus === 'pending'">
+          <dimension-weight-picker v-model="auditWeights" :show-presets="true" />
+          <div class="weights-tip">审核通过前请补充五维权重,合计需为 100%</div>
+        </el-form-item>
         <el-form-item label="驳回原因" v-if="currentRow.auditReason" style="color:#f56c6c;">
           {{ currentRow.auditReason }}
         </el-form-item>
@@ -80,11 +84,13 @@
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import { getActivityList, auditActivity } from '@/api/activity'
+import { getDimensionWeights } from '@/api/dimension-weight'
 import AuditDetailDrawer from './components/AuditDetailDrawer.vue'
+import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 
 export default {
   name: 'ActivityAudit',
-  components: { AuditDetailDrawer },
+  components: { AuditDetailDrawer, DimensionWeightPicker },
   mixins: [SortMixin],
   computed: {
     tableHeight() {
@@ -96,7 +102,7 @@ export default {
       list: [], loading: false, keyword: '', statusFilter: 'pending',
       sortableColumns: { id: 'ID', title: '活动名称', auditReason: '原因' },
       detailVisible: false, currentRow: null, auditLoading: false,
-      auditDimensionCode: ''
+      auditDimensionCode: '', auditWeights: []
     }
   },
   mounted() { this.loadData() },
@@ -115,21 +121,54 @@ export default {
       } catch (e) { this.$message.error('加载失败')
       } finally { this.loading = false }
     },
-    showDetail(row) { this.currentRow = row; this.auditDimensionCode = ''; this.detailVisible = true },
+    defaultWeights() {
+      return ['body', 'mind', 'wisdom', 'action', 'wealth'].map(dimension => ({
+        dimension, weight: 20, enabled: true
+      }))
+    },
+    async showDetail(row) {
+      this.currentRow = row
+      this.auditDimensionCode = row.dimensionCode || ''
+      this.auditWeights = this.defaultWeights()
+      this.detailVisible = true
+      if (row.auditStatus !== 'pending') return
+      try {
+        const res = await getDimensionWeights('activity', row.id)
+        const saved = res.data || []
+        if (saved.length === 5) {
+          this.auditWeights = saved.map(w => ({
+            dimension: w.dimension, weight: w.weight, enabled: w.enabled !== false
+          }))
+        }
+      } catch (e) {
+        // 保留默认均分,审核时人工调整
+      }
+    },
     handleApprove() {
       if (!this.auditDimensionCode) {
         this.$message.warning('审核通过时必须选择所属维度')
         return
       }
-      this.handleReview('publish')
+      const weights = this.auditWeights || []
+      const missing = ['body', 'mind', 'wisdom', 'action', 'wealth']
+        .some(d => !weights.find(w => w.dimension === d))
+      const sum = weights.reduce((total, w) => total + Number(w.weight || 0), 0)
+      if (missing || sum !== 100) {
+        this.$message.warning('五维权重需填写完整且合计为 100%')
+        return
+      }
+      this.handleReview('approved')
     },
-    handleReject(reason) { this.handleReview('reject', reason) },
+    handleReject(reason) { this.handleReview('rejected', reason) },
     async handleReview(action, reason) {
       if (!this.currentRow) return
       this.auditLoading = true
       try {
-        const params = { id: this.currentRow.id, action, rejectReason: action === 'reject' ? reason : '' }
-        if (action === 'publish') params.dimensionCode = this.auditDimensionCode
+        const params = { id: this.currentRow.id, action, rejectReason: action === 'rejected' ? reason : '' }
+        if (action === 'approved') {
+          params.dimensionCode = this.auditDimensionCode
+          params.dimensionWeights = this.auditWeights
+        }
         await auditActivity(params)
         this.$message.success('审核完成')
         this.detailVisible = false
@@ -144,6 +183,7 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.weights-tip { font-size: 12px; color: #909399; margin-top: 6px; }
 .sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
 .sort-header:hover { color: #409eff; }
 .sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }

+ 113 - 0
cfc-web/src/views/admin/review/__tests__/ActivityAudit.spec.js

@@ -0,0 +1,113 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils'
+import ActivityAudit from '../ActivityAudit.vue'
+import { auditActivity, getActivityList } from '@/api/activity'
+import { getDimensionWeights } from '@/api/dimension-weight'
+
+jest.mock('@/api/activity', () => ({
+  getActivityList: jest.fn(() => Promise.resolve({ data: { records: [] } })),
+  auditActivity: jest.fn(() => Promise.resolve({ code: 200 }))
+}))
+jest.mock('@/api/dimension-weight', () => ({
+  getDimensionWeights: jest.fn(() => Promise.resolve({ data: [] }))
+}))
+
+const localVue = createLocalVue()
+localVue.directive('loading', {})
+
+const stubs = [
+  'el-drawer', 'el-button', 'el-input', 'el-tag', 'el-form', 'el-form-item',
+  'el-select', 'el-option', 'el-table', 'el-table-column'
+]
+
+function mount() {
+  return shallowMount(ActivityAudit, {
+    localVue,
+    stubs,
+    mocks: { $message: { warning: jest.fn(), error: jest.fn(), success: jest.fn() } }
+  })
+}
+
+const validWeights = () => [
+  { dimension: 'body', weight: 20, enabled: true },
+  { dimension: 'mind', weight: 20, enabled: true },
+  { dimension: 'wisdom', weight: 20, enabled: true },
+  { dimension: 'action', weight: 20, enabled: true },
+  { dimension: 'wealth', weight: 20, enabled: true }
+]
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 0))
+
+describe('ActivityAudit 审核提交', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it('未选择所属维度时不提交', () => {
+    const wrapper = mount()
+    wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
+    wrapper.vm.auditDimensionCode = ''
+    wrapper.vm.handleApprove()
+
+    expect(wrapper.vm.$message.warning).toHaveBeenCalled()
+    expect(auditActivity).not.toHaveBeenCalled()
+  })
+
+  it('五维权重合计不为 100% 时不提交', () => {
+    const wrapper = mount()
+    wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
+    wrapper.vm.auditDimensionCode = 'body'
+    wrapper.vm.auditWeights = validWeights().map((w, i) => ({ ...w, weight: i === 0 ? 19 : 20 }))
+    wrapper.vm.handleApprove()
+
+    expect(wrapper.vm.$message.warning).toHaveBeenCalledWith('五维权重需填写完整且合计为 100%')
+    expect(auditActivity).not.toHaveBeenCalled()
+  })
+
+  it('校验通过后以 approved 提交并携带维度与五维权重', async () => {
+    const wrapper = mount()
+    wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
+    wrapper.vm.auditDimensionCode = 'body'
+    wrapper.vm.auditWeights = validWeights()
+    wrapper.vm.handleApprove()
+    await flush()
+
+    expect(auditActivity).toHaveBeenCalledWith({
+      id: 9,
+      action: 'approved',
+      rejectReason: '',
+      dimensionCode: 'body',
+      dimensionWeights: validWeights()
+    })
+  })
+
+  it('驳回时以 rejected 提交并携带原因', async () => {
+    const wrapper = mount()
+    wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
+    wrapper.vm.handleReject('材料不全')
+    await flush()
+
+    expect(auditActivity).toHaveBeenCalledWith({
+      id: 9,
+      action: 'rejected',
+      rejectReason: '材料不全'
+    })
+  })
+
+  it('打开详情时回填已保存的五维权重', async () => {
+    getDimensionWeights.mockResolvedValueOnce({
+      data: [
+        { dimension: 'body', weight: 40, enabled: true },
+        { dimension: 'mind', weight: 15, enabled: true },
+        { dimension: 'wisdom', weight: 15, enabled: true },
+        { dimension: 'action', weight: 15, enabled: true },
+        { dimension: 'wealth', weight: 15, enabled: true }
+      ]
+    })
+    const wrapper = mount()
+    wrapper.vm.showDetail({ id: 9, auditStatus: 'pending', dimensionCode: 'body' })
+    await flush()
+
+    expect(getDimensionWeights).toHaveBeenCalledWith('activity', 9)
+    expect(wrapper.vm.auditWeights.find(w => w.dimension === 'body').weight).toBe(40)
+  })
+})