Parcourir la source

推荐位管理调整

liuchengsen il y a 4 semaines
Parent
commit
31b20842d6

+ 6 - 0
backend-java/pom.xml

@@ -58,6 +58,12 @@
             <version>3.7.5</version>
         </dependency>
 
+        <!-- Jakarta Validation -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
         <!-- Spring Security + JWT -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 11 - 0
backend-java/src/main/java/com/pharmacopoeia/config/GlobalExceptionHandler.java

@@ -10,11 +10,13 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
+import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 import org.springframework.web.servlet.resource.NoResourceFoundException;
 
 import java.io.IOException;
+import java.util.stream.Collectors;
 
 @RestControllerAdvice
 public class GlobalExceptionHandler {
@@ -41,6 +43,15 @@ public class GlobalExceptionHandler {
         writeJson(response, HttpStatus.BAD_REQUEST, ApiResponse.error(400, e.getMessage()));
     }
 
+    /** @Valid 校验失败 → 400,汇总所有字段错误 */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public void handleValidation(MethodArgumentNotValidException e, HttpServletResponse response) throws IOException {
+        String msg = e.getBindingResult().getFieldErrors().stream()
+                .map(f -> f.getField() + ": " + f.getDefaultMessage())
+                .collect(Collectors.joining("; "));
+        writeJson(response, HttpStatus.BAD_REQUEST, ApiResponse.error(400, msg));
+    }
+
     /** 百炼 API 限流 → 503,告知前端服务暂时不可用 */
     @ExceptionHandler(DashScopeRateLimitExceededException.class)
     public void handleDashScopeRateLimit(DashScopeRateLimitExceededException e,

+ 11 - 13
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -6,6 +6,7 @@ import com.pharmacopoeia.dto.*;
 import com.pharmacopoeia.entity.QuickAsk;
 import com.pharmacopoeia.service.AdminKnowledgeService;
 import com.pharmacopoeia.service.BrandRecommendService;
+import jakarta.validation.Valid;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
@@ -210,26 +211,23 @@ public class AdminKnowledgeController {
     // ==================== 快捷提问标签管理(管理端,需要 JWT) ====================
 
     @GetMapping("/quick-asks")
-    public ResponseEntity<List<QuickAsk>> listAllQuickAsks() {
-        return ResponseEntity.ok(service.listAllQuickAsks());
+    public ResponseEntity<Map<String, Object>> listAllQuickAsks(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(service.listAllQuickAsks(page, pageSize));
     }
 
     @PostMapping("/quick-asks")
-    public ResponseEntity<Map<String, Object>> createQuickAsk(@RequestBody Map<String, Object> body) {
-        String label = (String) body.getOrDefault("label", "");
-        String question = (String) body.getOrDefault("question", label);
-        int sortOrder = body.get("sort_order") instanceof Integer i ? i : 0;
-        return ResponseEntity.ok(service.createQuickAsk(label, question, sortOrder));
+    public ResponseEntity<Map<String, Object>> createQuickAsk(@Valid @RequestBody QuickAskCreateRequest body) {
+        String question = body.getQuestion() != null ? body.getQuestion() : body.getLabel();
+        return ResponseEntity.ok(service.createQuickAsk(body.getLabel().trim(), question, body.getSortOrder()));
     }
 
     @PutMapping("/quick-asks/{id}")
     public ResponseEntity<Map<String, Object>> updateQuickAsk(
-            @PathVariable int id, @RequestBody Map<String, Object> body) {
-        String label = body.containsKey("label") ? (String) body.get("label") : null;
-        String question = body.containsKey("question") ? (String) body.get("question") : null;
-        Integer sortOrder = body.get("sort_order") instanceof Integer i ? i : null;
-        Boolean isActive = body.get("is_active") instanceof Boolean b ? b : null;
-        return ResponseEntity.ok(service.updateQuickAsk(id, label, question, sortOrder, isActive));
+            @PathVariable int id, @Valid @RequestBody QuickAskUpdateRequest body) {
+        return ResponseEntity.ok(service.updateQuickAsk(id, body.getLabel(),
+                body.getQuestion(), body.getSortOrder(), body.getIsActive()));
     }
 
    @DeleteMapping("/quick-asks/{id}")

+ 9 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AnalyticsController.java

@@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.Map;
+import java.util.List;
 
 @RestController
 @RequestMapping("/api/v1/analytics")
@@ -64,6 +65,14 @@ public class AnalyticsController {
                         .data(Map.of("status", "accepted")).build());
     }
 
+    // ==================== 事件类型列表 ====================
+
+    /** 获取所有事件类型,供前端切换 */
+    @GetMapping("/event-types")
+    public ResponseEntity<ApiResponse<List<String>>> getEventTypes() {
+        return ResponseEntity.ok(ApiResponse.success(analyticsService.getEventTypes()));
+    }
+
     // ==================== 概览 ====================
 
     /** 概览数字:total_events / active_users / page_views / searches / ai_qa

+ 24 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/QuickAskCreateRequest.java

@@ -0,0 +1,24 @@
+package com.pharmacopoeia.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class QuickAskCreateRequest {
+
+    @NotBlank(message = "请输入推荐位文字")
+    @Size(min = 2, max = 20, message = "推荐位文字长度需在2-20个字符之间")
+    private String label;
+
+    private String question;
+
+    @Builder.Default
+    private int sortOrder = 0;
+}

+ 21 - 0
backend-java/src/main/java/com/pharmacopoeia/dto/QuickAskUpdateRequest.java

@@ -0,0 +1,21 @@
+package com.pharmacopoeia.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class QuickAskUpdateRequest {
+
+    private String label;
+
+    private String question;
+
+    private Integer sortOrder;
+
+    private Boolean isActive;
+}

+ 8 - 2
backend-java/src/main/java/com/pharmacopoeia/repository/QuickAskRepository.java

@@ -1,6 +1,8 @@
 package com.pharmacopoeia.repository;
 
 import com.pharmacopoeia.entity.QuickAsk;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Modifying;
 import org.springframework.data.jpa.repository.Query;
@@ -13,14 +15,18 @@ import java.util.List;
 @Repository
 public interface QuickAskRepository extends JpaRepository<QuickAsk, Integer> {
 
-    List<QuickAsk> findAllByOrderBySortOrderAscIdAsc();
+    @Query("SELECT q FROM QuickAsk q ORDER BY q.sortOrder ASC, q.id ASC")
+    Page<QuickAsk> findAllByOrderBySortOrderAscIdAsc(Pageable pageable);
 
-    List<QuickAsk> findByIsActiveTrueOrderBySortOrderAscIdAsc();
+    @Query("SELECT q FROM QuickAsk q WHERE q.isActive = true ORDER BY q.sortOrder ASC, q.id DESC")
+    List<QuickAsk> findByIsActiveTrueOrderBySortOrderAscIdAsc(Pageable pageable);
 
     long countByLabel(String label);
 
     long countByLabelAndIdNot(String label, Integer id);
 
+    long countByIsActiveTrue();
+
     @Modifying
     @Transactional
     @Query("UPDATE QuickAsk q SET q.sortOrder = :sortOrder, q.updatedBy = :updatedBy WHERE q.id = :id")

+ 4 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/UserEventRepository.java

@@ -108,4 +108,8 @@ public interface UserEventRepository extends JpaRepository<UserEvent, Long>,
                "GROUP BY e2.userKey, e2.ip) sub")
     Page<Object[]> countByUserIpPaged(@Param("startTime") Instant startTime,
                                        @Param("endTime") Instant endTime, Pageable pageable);
+
+    /** ??????????? */
+    @Query("SELECT DISTINCT e.eventType FROM UserEvent e ORDER BY e.eventType")
+    List<String> findDistinctEventTypes();
 }

+ 16 - 6
backend-java/src/main/java/com/pharmacopoeia/security/JwtAuthFilter.java

@@ -61,13 +61,23 @@ public class JwtAuthFilter extends OncePerRequestFilter {
         String ip = IpUtils.getClientIp(request);
         MDC.put("ip", ip);
         try {
-            String uri = request.getRequestURI();
-            String method = request.getMethod();
-
-            // 无强鉴权:有 token 则解析身份,无 token 也直接放行
+            String header = request.getHeader("Authorization");
+            if (header != null && header.startsWith("Bearer ")) {
+                String token = header.substring(7);
+                try {
+                    String subject = jwtUtil.getSubject(token);
+                    if (subject != null && !subject.isBlank()) {
+                        var auth = new UsernamePasswordAuthenticationToken(
+                                subject, null, Collections.emptyList());
+                        SecurityContextHolder.getContext().setAuthentication(auth);
+                    }
+                } catch (Exception e) {
+                    log.debug("Token parse failed: {}", e.getMessage());
+                }
+            }
             filterChain.doFilter(request, response);
-            return;
-        }finally {
+        } finally {
+            SecurityContextHolder.clearContext();
             MDC.clear();
         }
     }

+ 11 - 9
backend-java/src/main/java/com/pharmacopoeia/security/SecurityConfig.java

@@ -9,9 +9,9 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
 import org.springframework.security.config.http.SessionCreationPolicy;
 import org.springframework.security.web.SecurityFilterChain;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import jakarta.servlet.http.HttpServletRequest;
 import org.springframework.web.cors.CorsConfiguration;
 import org.springframework.web.cors.CorsConfigurationSource;
-import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
 
 import java.util.List;
 
@@ -56,13 +56,15 @@ public class SecurityConfig {
 
     @Bean
     public CorsConfigurationSource corsConfigSource() {
-        var config = new CorsConfiguration();
-        config.setAllowedOriginPatterns(List.of("*"));
-        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
-        config.setAllowedHeaders(List.of("*"));
-        config.setAllowCredentials(true);
-        var source = new UrlBasedCorsConfigurationSource();
-        source.registerCorsConfiguration("/**", config);
-        return source;
+        return request -> {
+            var config = new CorsConfiguration();
+            // 回射请求的 Origin,避免 allowCredentials=true 与 "*" 互斥
+            String origin = request.getHeader("Origin");
+            config.setAllowedOrigins(List.of(origin != null ? origin : "*"));
+            config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+            config.setAllowedHeaders(List.of("*"));
+            config.setAllowCredentials(true);
+            return config;
+        };
     }
 }

+ 34 - 30
backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java

@@ -5,16 +5,15 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.pharmacopoeia.entity.Drug;
 import com.pharmacopoeia.entity.KnowledgePoint;
 import com.pharmacopoeia.entity.Question;
+import com.pharmacopoeia.entity.QuickAsk;
+import com.pharmacopoeia.entity.QuickAskAuditLog;
 import com.pharmacopoeia.repository.DrugRepository;
-import com.pharmacopoeia.repository.DrugChunkRepository;
 import com.pharmacopoeia.repository.KnowledgePointRepository;
 import com.pharmacopoeia.repository.QuestionRepository;
-import com.pharmacopoeia.repository.QuickAskRepository;
 import com.pharmacopoeia.repository.QuickAskAuditLogRepository;
-import com.pharmacopoeia.entity.QuickAsk;
-import com.pharmacopoeia.entity.QuickAskAuditLog;
+import com.pharmacopoeia.repository.QuickAskRepository;
+import com.pharmacopoeia.util.IpUtils;
 import org.springframework.data.domain.PageRequest;
-
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
@@ -22,9 +21,6 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.context.request.RequestContextHolder;
 import org.springframework.web.context.request.ServletRequestAttributes;
 
-import com.pharmacopoeia.util.IpUtils;
-
-
 import java.util.*;
 import java.util.function.Consumer;
 
@@ -32,7 +28,6 @@ import java.util.function.Consumer;
 public class AdminKnowledgeService {
 
     private final DrugRepository drugRepository;
-    private final DrugChunkRepository drugChunkRepository;
     private final KnowledgePointRepository knowledgePointRepository;
     private final QuestionRepository questionRepository;
     private final JdbcTemplate jdbc;
@@ -42,14 +37,12 @@ public class AdminKnowledgeService {
     private final QuickAskAuditLogRepository auditLogRepository;
 
     public AdminKnowledgeService(DrugRepository drugRepository,
-                                  DrugChunkRepository drugChunkRepository,
                                   KnowledgePointRepository knowledgePointRepository,
                                   QuestionRepository questionRepository,
                                   QuickAskRepository quickAskRepository,
                                   QuickAskAuditLogRepository auditLogRepository,
                                   JdbcTemplate jdbc) {
         this.drugRepository = drugRepository;
-        this.drugChunkRepository = drugChunkRepository;
         this.knowledgePointRepository = knowledgePointRepository;
         this.questionRepository = questionRepository;
         this.quickAskRepository = quickAskRepository;
@@ -299,31 +292,36 @@ public class AdminKnowledgeService {
 
     // ==================== 快捷提问标签管理 ====================
 
-    /** 获取所有激活的标签,按 sort_order 排序(公开接口,无需鉴权) */
+    /** 获取所有激活的标签,按 sort_order 排序(公开接口,无需鉴权),最多返回6个 */
     public List<QuickAsk> listQuickAsks() {
-        return quickAskRepository.findByIsActiveTrueOrderBySortOrderAscIdAsc();
+        return quickAskRepository.findByIsActiveTrueOrderBySortOrderAscIdAsc(
+                org.springframework.data.domain.PageRequest.of(0, MAX_ACTIVE_QUICK_ASKS));
     }
 
-    /** 管理端:获取所有标签(含禁用) */
-    public List<QuickAsk> listAllQuickAsks() {
-        return quickAskRepository.findAllByOrderBySortOrderAscIdAsc();
+    /** 管理端:分页获取所有标签(含禁用) */
+    public Map<String, Object> listAllQuickAsks(int page, int pageSize) {
+        var pageable = PageRequest.of(page - 1, pageSize);
+        var result = quickAskRepository.findAllByOrderBySortOrderAscIdAsc(pageable);
+        return Map.of(
+                "items", result.getContent(),
+                "page", page,
+                "page_size", pageSize,
+                "total", result.getTotalElements(),
+                "total_pages", result.getTotalPages()
+        );
     }
 
+    private static final int MAX_ACTIVE_QUICK_ASKS = 6;
+
     /** 新增标签 */
     @Transactional
     public Map<String, Object> createQuickAsk(String label, String question, int sortOrder) {
-        if (label == null || label.isBlank()) {
-            return Map.of("ok", false, "message", "请输入推荐位文字");
-        }
         String trimmedLabel = label.trim();
-        if (trimmedLabel.length() < 2 || trimmedLabel.length() > 20) {
-            return Map.of("ok", false, "message", "推荐位文字长度需在2-20个字符之间");
-        }
-        if (quickAskRepository.count() >= 6) {
-            return Map.of("ok", false, "message", "最多只能添加6个推荐位");
-        }
         if (quickAskRepository.countByLabel(trimmedLabel) > 0) {
-            return Map.of("ok", false, "message", "推荐位文字不能重复");
+            throw new IllegalArgumentException("推荐位文字不能重复");
+        }
+        if (quickAskRepository.count() >= MAX_ACTIVE_QUICK_ASKS) {
+            throw new IllegalArgumentException("最多只能添加" + MAX_ACTIVE_QUICK_ASKS + "个推荐位");
         }
         String operator = getCurrentOperator();
         QuickAsk qa = QuickAsk.builder()
@@ -343,13 +341,13 @@ public class AdminKnowledgeService {
     public Map<String, Object> updateQuickAsk(int id, String label, String question, Integer sortOrder, Boolean isActive) {
         var opt = quickAskRepository.findById(id);
         if (opt.isEmpty()) {
-            return Map.of("ok", false, "message", "未找到");
+            throw new IllegalArgumentException("未找到");
         }
         QuickAsk qa = opt.get();
         if (label != null && !label.isBlank()) {
             String trimmedLabel = label.trim();
             if (quickAskRepository.countByLabelAndIdNot(trimmedLabel, id) > 0) {
-                return Map.of("ok", false, "message", "推荐位文字不能重复");
+                throw new IllegalArgumentException("推荐位文字不能重复");
             }
             qa.setLabel(trimmedLabel);
         }
@@ -360,7 +358,13 @@ public class AdminKnowledgeService {
         qa.setUpdatedBy(operator);
         quickAskRepository.save(qa);
         writeAuditLog(id, "UPDATE", operator, qa.getLabel(), qa.getQuestion(), qa.getSortOrder(), qa.getIsActive());
-        return Map.of("ok", true, "id", id, "message", "已更新");
+
+        long activeCount = quickAskRepository.countByIsActiveTrue();
+        String message = "已更新";
+        if (Boolean.TRUE.equals(qa.getIsActive()) && activeCount > MAX_ACTIVE_QUICK_ASKS) {
+            message = "已更新(当前上架推荐位" + activeCount + "个,超过" + MAX_ACTIVE_QUICK_ASKS + "个上限)";
+        }
+        return Map.of("ok", true, "id", id, "message", message);
     }
 
     /** 删除标签 */
@@ -368,7 +372,7 @@ public class AdminKnowledgeService {
     public Map<String, Object> deleteQuickAsk(int id) {
         var opt = quickAskRepository.findById(id);
         if (opt.isEmpty()) {
-            return Map.of("ok", false, "message", "未找到");
+            throw new IllegalArgumentException("未找到");
         }
         QuickAsk qa = opt.get();
         String operator = getCurrentOperator();

+ 15 - 9
backend-java/src/main/java/com/pharmacopoeia/service/AnalyticsService.java

@@ -48,11 +48,11 @@ public class AnalyticsService {
                     .build();
             repo.save(event);
         } catch (Exception e) {
-            log.warn("埋点写入失败: type={}, error={}", eventType, e.getMessage());
+            log.warn("鍩嬬偣鍐欏叆澶辫触: type={}, error={}", eventType, e.getMessage());
         }
     }
 
-    // ==================== 埋点事件列表 ====================
+    // ==================== 鍩嬬偣浜嬩欢鍒楄〃 ====================
 
     public Map<String, Object> queryEvents(String eventType, String startDate, String endDate,
                                            int page, int pageSize) {
@@ -103,14 +103,12 @@ public class AnalyticsService {
         return m;
     }
 
-    // ==================== 概览数字 ====================
+    // ==================== 姒傝鏁板瓧 ====================
 
     /**
-     * 统计概览
+     * 缁熻姒傝
      * @param range today | yesterday | week | total | custom
-     * @param startDate custom 的起始日期(yyyy-MM-dd),仅 range=custom 时生效
-     * @param endDate   custom 的结束日期(yyyy-MM-dd),仅 range=custom 时生效
-     */
+     * @param startDate custom 鐨勮捣濮嬫棩鏈燂紙yyyy-MM-dd锛夛紝浠?range=custom 鏃剁敓鏁?     * @param endDate   custom 鐨勭粨鏉熸棩鏈燂紙yyyy-MM-dd锛夛紝浠?range=custom 鏃剁敓鏁?     */
     public Map<String, Object> queryOverview(String range, String startDate, String endDate) {
         if (range == null || range.isBlank()) {
             range = "total";
@@ -164,9 +162,9 @@ public class AnalyticsService {
         return overview;
     }
 
-    // ==================== 维度分页统计 ====================
+    // ==================== 缁村害鍒嗛〉缁熻 ====================
 
-    /** 分页维度统计:ip / url / type / user / user_ip */
+    /** 鍒嗛〉缁村害缁熻锛歩p / url / type / user / user_ip */
     public Map<String, Object> queryDimension(String dimension, String startDate, String endDate,
                                               int page, int pageSize) {
         Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
@@ -227,6 +225,14 @@ public class AnalyticsService {
                 .collect(Collectors.toList());
     }
 
+
+    // ==================== Event Types ====================
+
+    /** Return all distinct event types for frontend switching */
+    public List<String> getEventTypes() {
+        return repo.findDistinctEventTypes();
+    }
+
     // ==================== Helpers ====================
 
     private Specification<UserEvent> buildTimeSpec(String eventType, Instant startTime, Instant endTime) {

+ 17 - 17
backend-java/src/main/java/com/pharmacopoeia/service/BrandRecommendService.java

@@ -131,10 +131,10 @@ public class BrandRecommendService {
     public Map<String, Object> createBrand(Map<String, Object> body) {
         String name = (String) body.get("name");
         if (name == null || name.isBlank()) {
-            return Map.of("ok", false, "message", "品牌名称不能为空");
+            throw new IllegalArgumentException("品牌名称不能为空");
         }
         if (brandRepository.existsByNameAndIdNot(name.trim(), null)) {
-            return Map.of("ok", false, "message", "该品牌名称已存在");
+            throw new IllegalArgumentException("该品牌名称已存在");
         }
         Brand brand = Brand.builder()
                 .name(name.trim())
@@ -160,12 +160,12 @@ public class BrandRecommendService {
     @Transactional
     public Map<String, Object> updateBrand(int id, Map<String, Object> body) {
         var opt = brandRepository.findById(id);
-        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到该品牌");
+        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该品牌");
         Brand b = opt.get();
         String name = (String) body.get("name");
         if (name != null && !name.isBlank()) {
             if (brandRepository.existsByNameAndIdNot(name.trim(), id)) {
-                return Map.of("ok", false, "message", "该品牌名称已存在");
+                throw new IllegalArgumentException("该品牌名称已存在");
             }
             b.setName(name.trim());
         }
@@ -189,14 +189,14 @@ public class BrandRecommendService {
 
     @Transactional
     public Map<String, Object> deleteBrand(int id) {
-        if (!brandRepository.existsById(id)) return Map.of("ok", false, "message", "未找到该品牌");
+        if (!brandRepository.existsById(id)) throw new IllegalArgumentException("未找到该品牌");
         brandRepository.deleteById(id);
         return Map.of("ok", true, "message", "已删除");
     }
 
     public Map<String, Object> getBrand(int id) {
         var opt = brandRepository.findById(id);
-        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到该品牌");
+        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该品牌");
         Brand b = opt.get();
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("id", b.getId());
@@ -265,13 +265,13 @@ public class BrandRecommendService {
     @Transactional
     public Map<String, Object> createRule(String keyword, int brandId, int tier) {
         if (keyword == null || keyword.isBlank()) {
-            return Map.of("ok", false, "message", "关键词不能为空");
+            throw new IllegalArgumentException("关键词不能为空");
         }
         if (!brandRepository.existsById(brandId)) {
-            return Map.of("ok", false, "message", "品牌不存在");
+            throw new IllegalArgumentException("品牌不存在");
         }
         if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), null)) {
-            return Map.of("ok", false, "message", "该关键词已存在");
+            throw new IllegalArgumentException("该关键词已存在");
         }
         BrandRecommendRule rule = BrandRecommendRule.builder()
                 .keyword(keyword.trim())
@@ -286,29 +286,29 @@ public class BrandRecommendService {
     @Transactional
     public Map<String, Object> updateRule(int id, String keyword, Integer brandId, Integer tier, Boolean isActive) {
         var opt = ruleRepository.findById(id);
-        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到该规则");
+        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该规则");
         BrandRecommendRule rule = opt.get();
         if (keyword != null && !keyword.isBlank()) {
             if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), id)) {
-                return Map.of("ok", false, "message", "该关键词已存在");
+                throw new IllegalArgumentException("该关键词已存在");
             }
             rule.setKeyword(keyword.trim());
         }
         if (brandId != null) {
             if (!brandRepository.existsById(brandId)) {
-                return Map.of("ok", false, "message", "品牌不存在");
+                throw new IllegalArgumentException("品牌不存在");
             }
             rule.setBrandId(brandId);
         }
-        if (tier != null) rule.setTier(tier);
-        if (isActive != null) rule.setIsActive(isActive);
+        Optional.ofNullable(tier).ifPresent(rule::setTier);
+        Optional.ofNullable(isActive).ifPresent(rule::setIsActive);
         ruleRepository.save(rule);
         return Map.of("ok", true, "id", id, "message", "已更新");
     }
 
     @Transactional
     public Map<String, Object> deleteRule(int id) {
-        if (!ruleRepository.existsById(id)) return Map.of("ok", false, "message", "未找到该规则");
+        if (!ruleRepository.existsById(id)) throw new IllegalArgumentException("未找到该规则");
         ruleRepository.deleteById(id);
         return Map.of("ok", true, "message", "已删除");
     }
@@ -316,13 +316,13 @@ public class BrandRecommendService {
     @Transactional
     public Map<String, Object> toggleRule(int id, Boolean isActive) {
         var opt = ruleRepository.findById(id);
-        if (opt.isEmpty()) return Map.of("ok", false, "message", "未找到该规则");
+        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该规则");
         BrandRecommendRule rule = opt.get();
         boolean newState = isActive != null ? isActive : !Boolean.TRUE.equals(rule.getIsActive());
         rule.setIsActive(newState);
         ruleRepository.save(rule);
         return Map.of("ok", true, "id", id, "is_active", newState,
-                "message", newState ? "已启用" : "已停用");
+                "message", Boolean.TRUE.equals(newState) ? "已启用" : "已停用");
     }
 
     // ==================== CSV 导入导出 ====================