Pārlūkot izejas kodu

关键词匹配的导入导出更新

liuchengsen 3 nedēļas atpakaļ
vecāks
revīzija
769e7c2cc4

+ 49 - 25
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -1,6 +1,9 @@
 package com.pharmacopoeia.controller;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.http.MediaType;
+import java.nio.charset.StandardCharsets;
+import org.springframework.http.HttpHeaders;
 import com.pharmacopoeia.config.UnifiedResponse;
 import com.pharmacopoeia.dto.*;
 import com.pharmacopoeia.service.AdminKnowledgeService;
@@ -8,6 +11,7 @@ import com.pharmacopoeia.service.BrandRecommendService;
 import jakarta.validation.Valid;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.*;
 
@@ -281,23 +285,27 @@ public class AdminKnowledgeController {
     }
 
     @PostMapping("/brands/import")
-    public ResponseEntity<Map<String, Object>> importBrands(
-            @RequestBody Map<String, String> body) {
-        String csv = body.get("csv");
-        return ResponseEntity.ok(brandRecommendService.importBrandsCsv(csv));
+    public ResponseEntity<Map<String, Object>> importBrands(@RequestParam("file") MultipartFile file) {
+        try {
+            String csv = new String(file.getBytes(), StandardCharsets.UTF_8);
+            return ResponseEntity.ok(brandRecommendService.importBrandsCsv(csv));
+        } catch (Exception e) {
+            return ResponseEntity.ok(Map.of("ok", false, "message", e.getMessage()));
+        }
     }
 
     @GetMapping("/brands/export")
-    public ResponseEntity<Map<String, Object>> exportBrands() {
-        String csv = brandRecommendService.exportBrandsCsv();
-        return ResponseEntity.ok(Map.of("csv", csv));
-    }
-
-    @GetMapping("/brands/template")
-    public ResponseEntity<String> brandTemplate() {
-        return ResponseEntity.ok("name,function_indication,usage_dosage,contraindication,ingredients,properties,specification,adverse_reactions,precautions,execution_standard,storage,jump_url,description,sort_order");
+    public ResponseEntity<?> exportBrands(@RequestParam(required = false) String ids) {
+        Set<Integer> idSet = parseIds(ids);
+        if (idSet.isEmpty()) {
+            return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "请选择要导出的品牌"));
+        }
+        byte[] bytes = brandRecommendService.exportBrandsCsvBytes(idSet);
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=brands.csv")
+                .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
+                .body(bytes);
     }
-
    // ==================== 品牌推荐规则管理 ====================
 
     @GetMapping("/brand-recommend-rules")
@@ -342,22 +350,38 @@ public class AdminKnowledgeController {
 
     @PostMapping("/brand-recommend-rules/import")
     public ResponseEntity<Map<String, Object>> importBrandRecommendRules(
-            @RequestBody Map<String, String> body) {
-        String csv = body.get("csv");
-        return ResponseEntity.ok(brandRecommendService.importRulesCsv(csv));
+            @RequestParam("file") MultipartFile file) {
+        try {
+            String csv = new String(file.getBytes(), StandardCharsets.UTF_8);
+            return ResponseEntity.ok(brandRecommendService.importRulesCsv(csv));
+        } catch (Exception e) {
+            return ResponseEntity.ok(Map.of("ok", false, "message", e.getMessage()));
+        }
     }
 
-    @GetMapping("/brand-recommend-rules/export")
-    public ResponseEntity<Map<String, Object>> exportBrandRecommendRules() {
-        String csv = brandRecommendService.exportRulesCsv();
-        return ResponseEntity.ok(Map.of("csv", csv));
+    private static Set<Integer> parseIds(String ids) {
+        if (ids == null || ids.isBlank()) {
+            return Set.of();
+        }
+        return java.util.Arrays.stream(ids.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .map(Integer::parseInt)
+                .collect(java.util.stream.Collectors.toSet());
     }
-
-    @GetMapping("/brand-recommend-rules/template")
-    public ResponseEntity<String> brandRuleTemplate() {
-        return ResponseEntity.ok("keyword,brand_id,tier");
+    @GetMapping("/brand-recommend-rules/export")
+    public ResponseEntity<?> exportBrandRecommendRules(
+            @RequestParam(required = false) String ids) {
+        Set<Integer> idSet = parseIds(ids);
+        if (idSet.isEmpty()) {
+            return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "请选择要导出的推荐规则"));
+        }
+        byte[] bytes = brandRecommendService.exportRulesCsvBytes(idSet);
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=brand_recommend_rules.csv")
+                .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
+                .body(bytes);
     }
-
    // ==================== Helpers ====================
 
     private int parseCount(ImportRequest req) {

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

@@ -9,9 +9,9 @@ import java.util.List;
 @Repository
 public interface BrandRepository extends JpaRepository<Brand, Integer> {
 
-    List<Brand> findByIsActiveTrueOrderBySortOrderAsc();
-
     List<Brand> findAllByOrderBySortOrderAsc();
 
     boolean existsByNameAndIdNot(String name, Integer id);
+
+    Brand findByName(String name);
 }

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

@@ -7,8 +7,10 @@ import com.pharmacopoeia.repository.BrandRepository;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.nio.charset.StandardCharsets;
 import java.util.*;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 @Service
@@ -23,59 +25,59 @@ public class BrandRecommendService {
         this.brandRepository = brandRepository;
     }
 
-    // ==================== 层级匹配 ====================
+    // ==================== field constants ====================
+
+    private static final List<String> BRAND_CSV_COLS = List.of(
+            "name", "function_indication", "usage_dosage", "contraindication",
+            "ingredients", "properties", "specification", "adverse_reactions",
+            "precautions", "execution_standard", "storage", "jump_url", "description", "sort_order"
+    );
+    private static final String BRANDS_CSV_HEADER = String.join(",", BRAND_CSV_COLS);
+    private static final String RULES_CSV_HEADER = "keyword,brand_id,brand_name,tier,is_active";
+
+    // non-name, non-sort_order columns
+    private static final List<String> BRAND_TEXT_COLS = BRAND_CSV_COLS.stream()
+            .filter(c -> !"name".equals(c) && !"sort_order".equals(c))
+            .toList();
+
+    // ==================== tier matching ====================
 
     public List<Map<String, Object>> match(List<Map<String, Object>> sources, String fullAnswer) {
-        // 仅加载 is_active=true 的规则,已停用的不参与匹配
         List<BrandRecommendRule> rules = ruleRepository.findByIsActiveTrueOrderByTierAscKeywordAsc();
-        if (rules.isEmpty()) {
-            return List.of();
-        }
+        return rules.isEmpty() ? List.of() : doMatch(rules, sources, fullAnswer);
+    }
+
+    private List<Map<String, Object>> doMatch(List<BrandRecommendRule> rules,
+                                               List<Map<String, Object>> sources, String fullAnswer) {
         Map<Integer, List<BrandRecommendRule>> byTier = rules.stream()
                 .collect(Collectors.groupingBy(BrandRecommendRule::getTier, LinkedHashMap::new, Collectors.toList()));
 
-        Set<String> sourceNames = sources.stream()
+        Set<String> texts = sources.stream()
                 .map(s -> (String) s.getOrDefault("name", ""))
                 .filter(n -> !n.isEmpty())
-                .collect(Collectors.toSet());
-
-        for (Map.Entry<Integer, List<BrandRecommendRule>> entry : byTier.entrySet()) {
-            List<Map<String, Object>> matches = findMatches(entry.getValue(), sourceNames);
-            if (!matches.isEmpty()) {
-                return matches;
-            }
-        }
-
-        // 全文回退也按 tier 逐层尝试
-        if (fullAnswer != null && !fullAnswer.isEmpty()) {
-            for (Map.Entry<Integer, List<BrandRecommendRule>> entry : byTier.entrySet()) {
-                List<Map<String, Object>> matches = findMatches(entry.getValue(), Set.of(fullAnswer));
-                if (!matches.isEmpty()) {
-                    return matches;
-                }
-            }
-        }
-        return List.of();
+                .collect(Collectors.toCollection(HashSet::new));
+        Optional.ofNullable(fullAnswer).filter(a -> !a.isEmpty()).ifPresent(texts::add);
+
+        return byTier.values().stream()
+                .map(tierRules -> findMatches(tierRules, texts))
+                .filter(m -> !m.isEmpty())
+                .findFirst()
+                .orElse(List.of());
     }
 
     private List<Map<String, Object>> findMatches(List<BrandRecommendRule> rules, Set<String> targets) {
-        // 一次性加载所有用到的 Brand
         Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
         Map<Integer, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
                 .collect(Collectors.toMap(Brand::getId, b -> b));
 
-        Set<Integer> seenRuleIds = new HashSet<>();    // 规则去重(同规则只命中一次)
-        Set<Integer> seenBrandIds = new HashSet<>();   // 品牌去重(同品牌只返回一次)
+        Set<Integer> seenRuleIds = new HashSet<>();
+        Set<Integer> seenBrandIds = new HashSet<>();
         List<Map<String, Object>> result = new ArrayList<>();
         for (BrandRecommendRule rule : rules) {
-            for (String target : targets) {
-                if (target.toLowerCase().contains(rule.getKeyword().toLowerCase())) {
-                    if (seenRuleIds.add(rule.getId()) && seenBrandIds.add(rule.getBrandId())) {
-                        Brand brand = brandMap.get(rule.getBrandId());
-                        result.add(buildBrandResult(rule, brand));
-                    }
-                    break;
-                }
+            String kwLower = rule.getKeyword().toLowerCase();
+            boolean hit = targets.stream().anyMatch(t -> t.toLowerCase().contains(kwLower));
+            if (hit && seenRuleIds.add(rule.getId()) && seenBrandIds.add(rule.getBrandId())) {
+                result.add(buildBrandResult(rule, brandMap.get(rule.getBrandId())));
             }
         }
         return result;
@@ -104,38 +106,26 @@ public class BrandRecommendService {
         return m;
     }
 
-    // ==================== 品牌 CRUD ====================
+    // ==================== brand CRUD ====================
 
     public Map<String, Object> listBrands(String keyword, int page, int pageSize) {
         List<Brand> all = brandRepository.findAllByOrderBySortOrderAsc();
-        if (keyword != null && !keyword.isBlank()) {
-            String kw = keyword.toLowerCase();
-            all = all.stream()
-                .filter(b -> b.getName().toLowerCase().contains(kw))
-                .collect(Collectors.toList());
-        }
-        int total = all.size();
-        int from = (page - 1) * pageSize;
-        int to = Math.min(from + pageSize, total);
-        List<Brand> pageItems = from < total ? all.subList(from, to) : List.of();
-        return Map.of(
-            "items", (Object) pageItems,
-            "page", page,
-            "page_size", pageSize,
-            "total", total,
-            "total_pages", (int) Math.ceil((double) total / pageSize)
-        );
+        Optional.ofNullable(keyword)
+                .filter(k -> !k.isBlank())
+                .ifPresent(k -> {
+                    String kw = k.toLowerCase();
+                    all.removeIf(b -> !b.getName().toLowerCase().contains(kw));
+                });
+        return paginate(all, page, pageSize);
     }
 
     @Transactional
     public Map<String, Object> createBrand(Map<String, Object> body) {
-        String name = (String) body.get("name");
-        if (name == null || name.isBlank()) {
-            throw new IllegalArgumentException("品牌名称不能为空");
-        }
+        String name = require(body);
         if (brandRepository.existsByNameAndIdNot(name.trim(), null)) {
-            throw new IllegalArgumentException("该品牌名称已存在");
+            throw new IllegalArgumentException("brand name already exists");
         }
+
         Brand brand = Brand.builder()
                 .name(name.trim())
                 .functionIndication(str(body, "function_indication"))
@@ -150,98 +140,79 @@ public class BrandRecommendService {
                 .storage(str(body, "storage"))
                 .jumpUrl(str(body, "jump_url"))
                 .description(str(body, "description"))
-                .sortOrder(body.get("sort_order") instanceof Number n ? n.intValue() : 0)
+                .sortOrder(intVal(body))
                 .isActive(true)
                 .build();
         brand = brandRepository.save(brand);
-        return Map.of("ok", true, "id", brand.getId(), "message", "已创建");
+        return Map.of("ok", true, "id", brand.getId(), "message", "created");
     }
 
     @Transactional
     public Map<String, Object> updateBrand(int id, Map<String, Object> body) {
-        var opt = brandRepository.findById(id);
-        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)) {
-                throw new IllegalArgumentException("该品牌名称已存在");
-            }
-            b.setName(name.trim());
-        }
-        applyStr(body, "function_indication", b::setFunctionIndication);
-        applyStr(body, "usage_dosage", b::setUsageDosage);
-        applyStr(body, "contraindication", b::setContraindication);
-        applyStr(body, "ingredients", b::setIngredients);
-        applyStr(body, "properties", b::setProperties);
-        applyStr(body, "specification", b::setSpecification);
-        applyStr(body, "adverse_reactions", b::setAdverseReactions);
-        applyStr(body, "precautions", b::setPrecautions);
-        applyStr(body, "execution_standard", b::setExecutionStandard);
-        applyStr(body, "storage", b::setStorage);
-        applyStr(body, "jump_url", b::setJumpUrl);
-        applyStr(body, "description", b::setDescription);
-        Optional.ofNullable((Number) body.get("sort_order")).ifPresent(n -> b.setSortOrder(n.intValue()));
-        Optional.ofNullable((Boolean) body.get("is_active")).ifPresent(b::setIsActive);
+        Brand b = brandRepository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("brand not found"));
+
+        Optional.ofNullable(body.get("name"))
+                .map(Object::toString).map(String::trim).filter(n -> !n.isBlank())
+                .ifPresent(n -> {
+                    if (brandRepository.existsByNameAndIdNot(n, id)) {
+                        throw new IllegalArgumentException("brand name already exists");
+                    }
+                    b.setName(n);
+                });
+
+        BRAND_TEXT_COLS.forEach(key -> applyStr(body, key, fieldSetter(b, key)));
+
+        Optional.ofNullable(body.get("sort_order"))
+                .filter(v -> v instanceof Number)
+                .ifPresent(v -> b.setSortOrder(((Number) v).intValue()));
+        Optional.ofNullable(body.get("is_active"))
+                .filter(v -> v instanceof Boolean)
+                .ifPresent(v -> b.setIsActive((Boolean) v));
+
         brandRepository.save(b);
-        return Map.of("ok", true, "id", id, "message", "已更新");
+        return Map.of("ok", true, "id", id, "message", "updated");
     }
 
     @Transactional
     public Map<String, Object> deleteBrand(int id) {
-        if (!brandRepository.existsById(id)) throw new IllegalArgumentException("未找到该品牌");
+        brandRepository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("brand not found"));
         brandRepository.deleteById(id);
-        return Map.of("ok", true, "message", "已删除");
+        return Map.of("ok", true, "message", "deleted");
     }
 
     public Map<String, Object> getBrand(int id) {
-        var opt = brandRepository.findById(id);
-        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该品牌");
-        Brand b = opt.get();
+        Brand b = brandRepository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("brand not found"));
+
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("id", b.getId());
         m.put("name", b.getName());
-        m.put("function_indication", b.getFunctionIndication());
-        m.put("usage_dosage", b.getUsageDosage());
-        m.put("contraindication", b.getContraindication());
-        m.put("ingredients", b.getIngredients());
-        m.put("properties", b.getProperties());
-        m.put("specification", b.getSpecification());
-        m.put("adverse_reactions", b.getAdverseReactions());
-        m.put("precautions", b.getPrecautions());
-        m.put("execution_standard", b.getExecutionStandard());
-        m.put("storage", b.getStorage());
-        m.put("jump_url", b.getJumpUrl());
-        m.put("description", b.getDescription());
-        m.put("sort_order", b.getSortOrder());
+        BRAND_CSV_COLS.stream().filter(c -> !"name".equals(c))
+                .forEach(c -> m.put(c, nullToEmpty(brandField(b, c))));
         m.put("is_active", b.getIsActive());
         m.put("created_at", b.getCreatedAt());
         m.put("updated_at", b.getUpdatedAt());
         return m;
     }
 
-    // ==================== 匹配规则 CRUD ====================
+    // ==================== rule CRUD ====================
 
     public Map<String, Object> listRules(String keyword, int page, int pageSize) {
         List<BrandRecommendRule> all = ruleRepository.findAllByOrderByTierAscKeywordAsc();
-        if (keyword != null && !keyword.isBlank()) {
-            all = all.stream()
-                .filter(r -> r.getKeyword().contains(keyword))
-                .collect(Collectors.toList());
-        }
+        Optional.ofNullable(keyword)
+                .filter(k -> !k.isBlank())
+                .ifPresent(k -> all.removeIf(r -> !r.getKeyword().contains(k)));
 
-        // 加载关联品牌名
         Set<Integer> brandIds = all.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
         Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
                 .collect(Collectors.toMap(Brand::getId, Brand::getName));
 
-        int total = all.size();
-        int from = (page - 1) * pageSize;
-        int to = Math.min(from + pageSize, total);
-        List<BrandRecommendRule> pageItems = from < total ? all.subList(from, to) : List.of();
-
-        List<Map<String, Object>> items = new ArrayList<>();
-        for (BrandRecommendRule r : pageItems) {
+        Map<String, Object> result = new HashMap<>(paginate(all, page, pageSize));
+        @SuppressWarnings("unchecked")
+        List<BrandRecommendRule> items = (List<BrandRecommendRule>) result.get("items");
+        List<Map<String, Object>> enriched = items.stream().map(r -> {
             Map<String, Object> m = new LinkedHashMap<>();
             m.put("id", r.getId());
             m.put("keyword", r.getKeyword());
@@ -251,95 +222,83 @@ public class BrandRecommendService {
             m.put("is_active", r.getIsActive());
             m.put("created_at", r.getCreatedAt());
             m.put("updated_at", r.getUpdatedAt());
-            items.add(m);
-        }
-        return Map.of(
-            "items", (Object) items,
-            "page", page,
-            "page_size", pageSize,
-            "total", total,
-            "total_pages", (int) Math.ceil((double) total / pageSize)
-        );
+            return m;
+        }).collect(Collectors.toList());
+        result.put("items", enriched);
+        return result;
     }
 
     @Transactional
     public Map<String, Object> createRule(String keyword, int brandId, int tier) {
-        if (keyword == null || keyword.isBlank()) {
-            throw new IllegalArgumentException("关键词不能为空");
-        }
-        if (!brandRepository.existsById(brandId)) {
-            throw new IllegalArgumentException("品牌不存在");
-        }
+        Optional.ofNullable(keyword).filter(k -> !k.isBlank())
+                .orElseThrow(() -> new IllegalArgumentException("keyword required"));
+        Optional.of(brandId).filter(brandRepository::existsById)
+                .orElseThrow(() -> new IllegalArgumentException("brand not found"));
         if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), null)) {
-            throw new IllegalArgumentException("该关键词已存在");
+            throw new IllegalArgumentException("keyword already exists");
         }
+
         BrandRecommendRule rule = BrandRecommendRule.builder()
-                .keyword(keyword.trim())
-                .brandId(brandId)
-                .tier(tier)
-                .isActive(true)
-                .build();
+                .keyword(keyword.trim()).brandId(brandId).tier(tier).isActive(true).build();
         rule = ruleRepository.save(rule);
-        return Map.of("ok", true, "id", rule.getId(), "message", "已创建");
+        return Map.of("ok", true, "id", rule.getId(), "message", "created");
     }
 
     @Transactional
     public Map<String, Object> updateRule(int id, String keyword, Integer brandId, Integer tier, Boolean isActive) {
-        var opt = ruleRepository.findById(id);
-        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该规则");
-        BrandRecommendRule rule = opt.get();
-        if (keyword != null && !keyword.isBlank()) {
-            if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), id)) {
-                throw new IllegalArgumentException("该关键词已存在");
-            }
-            rule.setKeyword(keyword.trim());
-        }
-        if (brandId != null) {
-            if (!brandRepository.existsById(brandId)) {
-                throw new IllegalArgumentException("品牌不存在");
+        BrandRecommendRule rule = ruleRepository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("rule not found"));
+
+        Optional.ofNullable(keyword).filter(k -> !k.isBlank()).ifPresent(k -> {
+            if (ruleRepository.existsByKeywordAndIdNot(k.trim(), id)) {
+                throw new IllegalArgumentException("keyword already exists");
             }
-            rule.setBrandId(brandId);
-        }
+            rule.setKeyword(k.trim());
+        });
+        Optional.ofNullable(brandId)
+                .filter(brandRepository::existsById)
+                .ifPresent(rule::setBrandId);
         Optional.ofNullable(tier).ifPresent(rule::setTier);
         Optional.ofNullable(isActive).ifPresent(rule::setIsActive);
+
         ruleRepository.save(rule);
-        return Map.of("ok", true, "id", id, "message", "已更新");
+        return Map.of("ok", true, "id", id, "message", "updated");
     }
 
     @Transactional
     public Map<String, Object> deleteRule(int id) {
-        if (!ruleRepository.existsById(id)) throw new IllegalArgumentException("未找到该规则");
+        ruleRepository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("rule not found"));
         ruleRepository.deleteById(id);
-        return Map.of("ok", true, "message", "已删除");
+        return Map.of("ok", true, "message", "deleted");
     }
 
     @Transactional
     public Map<String, Object> toggleRule(int id, Boolean isActive) {
-        var opt = ruleRepository.findById(id);
-        if (opt.isEmpty()) throw new IllegalArgumentException("未找到该规则");
-        BrandRecommendRule rule = opt.get();
+        BrandRecommendRule rule = ruleRepository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("rule not found"));
         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", Boolean.TRUE.equals(newState) ? "已启用" : "已停用");
+                "message", newState ? "enabled" : "disabled");
     }
 
-    // ==================== CSV 导入导出 ====================
+    // ==================== CSV import/export ====================
 
     public Map<String, Object> importBrandsCsv(String csv) {
+        List<String> lines = csvLines(csv);
+        if (lines.isEmpty() || !lines.getFirst().equals(BRANDS_CSV_HEADER)) {
+            throw new IllegalArgumentException("CSV format incorrect: expected header " + BRANDS_CSV_HEADER);
+        }
+
         int success = 0, failed = 0;
-        for (String line : csv.split("\\n")) {
-            line = line.trim();
-            if (line.isEmpty() || line.startsWith("#")) continue;
-            String[] cols = line.split(",", 14);
-            if (cols.length < 1) { failed++; continue; }
+        for (int i = 1; i < lines.size(); i++) {
+            String[] cols = lines.get(i).split(",", BRAND_CSV_COLS.size());
             try {
                 String name = cols[0].trim();
-                if (name.isEmpty() || brandRepository.existsByNameAndIdNot(name, null)) {
-                    failed++; continue;
-                }
-                Brand b = Brand.builder()
+                if (name.isEmpty() || brandRepository.existsByNameAndIdNot(name, null)) { failed++; continue; }
+                brandRepository.save(Brand.builder()
                         .name(name)
                         .functionIndication(col(cols, 1))
                         .usageDosage(col(cols, 2))
@@ -355,100 +314,193 @@ public class BrandRecommendService {
                         .description(col(cols, 12))
                         .sortOrder(cols.length > 13 ? Integer.parseInt(cols[13].trim()) : 0)
                         .isActive(true)
-                        .build();
-                brandRepository.save(b);
+                        .build());
                 success++;
             } catch (Exception e) { failed++; }
         }
         return Map.of("ok", true, "success", success, "failed", failed);
     }
 
-    public String exportBrandsCsv() {
-        List<Brand> brands = brandRepository.findAllByOrderBySortOrderAsc();
-        StringBuilder sb = new StringBuilder(
-                "name,function_indication,usage_dosage,contraindication,ingredients,properties,specification,adverse_reactions,precautions,execution_standard,storage,jump_url,description,sort_order\\n");
-        for (Brand b : brands) {
-            sb.append(esc(b.getName())).append(",")
-              .append(esc(b.getFunctionIndication())).append(",")
-              .append(esc(b.getUsageDosage())).append(",")
-              .append(esc(b.getContraindication())).append(",")
-              .append(esc(b.getIngredients())).append(",")
-              .append(esc(b.getProperties())).append(",")
-              .append(esc(b.getSpecification())).append(",")
-              .append(esc(b.getAdverseReactions())).append(",")
-              .append(esc(b.getPrecautions())).append(",")
-              .append(esc(b.getExecutionStandard())).append(",")
-              .append(esc(b.getStorage())).append(",")
-              .append(esc(b.getJumpUrl())).append(",")
-              .append(esc(b.getDescription())).append(",")
-              .append(b.getSortOrder()).append("\\n");
-        }
+    public String exportBrandsCsv(Set<Integer> ids) {
+        List<Brand> brands = ids.isEmpty() ? List.of() : brandRepository.findAllById(ids);
+        StringBuilder sb = new StringBuilder(BRANDS_CSV_HEADER).append("\n");
+        List<Function<Brand, String>> extractors = List.of(
+                b -> esc(b.getName()),
+                b -> esc(b.getFunctionIndication()),
+                b -> esc(b.getUsageDosage()),
+                b -> esc(b.getContraindication()),
+                b -> esc(b.getIngredients()),
+                b -> esc(b.getProperties()),
+                b -> esc(b.getSpecification()),
+                b -> esc(b.getAdverseReactions()),
+                b -> esc(b.getPrecautions()),
+                b -> esc(b.getExecutionStandard()),
+                b -> esc(b.getStorage()),
+                b -> esc(b.getJumpUrl()),
+                b -> esc(b.getDescription()),
+                b -> String.valueOf(b.getSortOrder())
+        );
+        brands.forEach(b -> sb.append(extractors.stream()
+                .map(f -> f.apply(b)).collect(Collectors.joining(","))).append("\n"));
         return sb.toString();
     }
 
     public Map<String, Object> importRulesCsv(String csv) {
+        List<String> lines = csvLines(csv);
+        if (lines.isEmpty() || !lines.getFirst().equals(RULES_CSV_HEADER)) {
+            throw new IllegalArgumentException("CSV format incorrect: expected header " + RULES_CSV_HEADER);
+        }
+
         int success = 0, failed = 0;
-        for (String line : csv.split("\\n")) {
-            line = line.trim();
-            if (line.isEmpty() || line.startsWith("#")) continue;
-            String[] cols = line.split(",", 3);
-            if (cols.length < 2) { failed++; continue; }
+        for (int i = 1; i < lines.size(); i++) {
+            String[] cols = lines.get(i).split(",", 5);
             try {
                 String kw = cols[0].trim();
-                int brandId = Integer.parseInt(cols[1].trim());
-                int tier = cols.length > 2 ? Integer.parseInt(cols[2].trim()) : 1;
-                if (!brandRepository.existsById(brandId)) { failed++; continue; }
+                if (kw.isEmpty()) { failed++; continue; }
+
+                int brandId = cols.length > 1 ? parseIntOrDefault(cols[1], 0) : 0;
+                String brandName = cols.length > 2 ? cols[2].trim() : "";
+                Brand matched = brandId > 0 && brandRepository.existsById(brandId)
+                        ? null
+                        : brandRepository.findByName(brandName);
+                int resolvedId = matched != null ? matched.getId() : brandId;
+                if (resolvedId <= 0 || !brandRepository.existsById(resolvedId)) { failed++; continue; }
+
+                int tier = cols.length > 3 ? parseIntOrDefault(cols[3], 1) : 1;
                 if (ruleRepository.existsByKeywordAndIdNot(kw, null)) { failed++; continue; }
                 ruleRepository.save(BrandRecommendRule.builder()
-                        .keyword(kw).brandId(brandId).tier(tier).isActive(true).build());
+                        .keyword(kw).brandId(resolvedId).tier(tier).isActive(true).build());
                 success++;
             } catch (Exception e) { failed++; }
         }
         return Map.of("ok", true, "success", success, "failed", failed);
     }
 
-    public String exportRulesCsv() {
-        List<BrandRecommendRule> rules = ruleRepository.findAllByOrderByTierAscKeywordAsc();
+    public String exportRulesCsv(Set<Integer> ids) {
+        List<BrandRecommendRule> rules = ids.isEmpty() ? List.of() : ruleRepository.findAllById(ids);
         Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
         Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
                 .collect(Collectors.toMap(Brand::getId, Brand::getName));
-        StringBuilder sb = new StringBuilder("keyword,brand_id,brand_name,tier,is_active\\n");
-        for (BrandRecommendRule r : rules) {
-            sb.append(r.getKeyword()).append(",")
-              .append(r.getBrandId()).append(",")
-              .append(brandNames.getOrDefault(r.getBrandId(), "")).append(",")
-              .append(r.getTier()).append(",")
-              .append(r.getIsActive()).append("\\n");
-        }
+        StringBuilder sb = new StringBuilder(RULES_CSV_HEADER).append("\n");
+        rules.forEach(r -> sb.append(r.getKeyword()).append(",")
+                .append(r.getBrandId()).append(",")
+                .append(brandNames.getOrDefault(r.getBrandId(), "")).append(",")
+                .append(r.getTier()).append(",")
+                .append(r.getIsActive()).append("\n"));
         return sb.toString();
     }
 
-    // ==================== Helpers ====================
+    public byte[] exportBrandsCsvBytes(Set<Integer> ids) {
+        return exportBrandsCsv(ids).getBytes(StandardCharsets.UTF_8);
+    }
+
+    public byte[] exportRulesCsvBytes(Set<Integer> ids) {
+        return exportRulesCsv(ids).getBytes(StandardCharsets.UTF_8);
+    }
 
-    private static String str(Map<String, Object> body, String key) {
-        Object v = body.get(key);
-        if (v == null) return null;
-        String s = v.toString().trim();
-        return s.isEmpty() ? null : s;
+    // ==================== helpers ====================
+
+    private static <T> Map<String, Object> paginate(List<T> all, int page, int pageSize) {
+        int total = all.size();
+        int from = (page - 1) * pageSize;
+        int to = Math.min(from + pageSize, total);
+        List<T> items = from < total ? all.subList(from, to) : List.of();
+        return Map.of(
+            "items", items,
+            "page", page,
+            "page_size", pageSize,
+            "total", total,
+            "total_pages", (int) Math.ceil((double) total / pageSize)
+        );
+    }
+
+    private static List<String> csvLines(String csv) {
+        return Arrays.stream(csv.replace("\r\n", "\n").replace("\r", "\n").split("\n"))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty() && !s.startsWith("#"))
+                .collect(Collectors.toList());
+    }
+
+    private static String require(Map<String, Object> body) {
+        String v = str(body, "name");
+        if (v == null || v.isBlank()) {
+            throw new IllegalArgumentException("brand name required");
+        }
+        return v;
+    }
+
+    private static int parseIntOrDefault(String s, int def) {
+        try { return Integer.parseInt(s.trim()); } catch (NumberFormatException e) { return def; }
+    }
+
+    private static Consumer<String> fieldSetter(Brand b, String col) {
+        return switch (col) {
+            case "function_indication" -> b::setFunctionIndication;
+            case "usage_dosage" -> b::setUsageDosage;
+            case "contraindication" -> b::setContraindication;
+            case "ingredients" -> b::setIngredients;
+            case "properties" -> b::setProperties;
+            case "specification" -> b::setSpecification;
+            case "adverse_reactions" -> b::setAdverseReactions;
+            case "precautions" -> b::setPrecautions;
+            case "execution_standard" -> b::setExecutionStandard;
+            case "storage" -> b::setStorage;
+            case "jump_url" -> b::setJumpUrl;
+            case "description" -> b::setDescription;
+            default -> v -> {};
+        };
+    }
+
+    private static String brandField(Brand b, String col) {
+        return switch (col) {
+            case "function_indication" -> b.getFunctionIndication();
+            case "usage_dosage" -> b.getUsageDosage();
+            case "contraindication" -> b.getContraindication();
+            case "ingredients" -> b.getIngredients();
+            case "properties" -> b.getProperties();
+            case "specification" -> b.getSpecification();
+            case "adverse_reactions" -> b.getAdverseReactions();
+            case "precautions" -> b.getPrecautions();
+            case "execution_standard" -> b.getExecutionStandard();
+            case "storage" -> b.getStorage();
+            case "jump_url" -> b.getJumpUrl();
+            case "description" -> b.getDescription();
+            case "sort_order" -> String.valueOf(b.getSortOrder());
+            default -> "";
+        };
     }
 
-    private static String nullToEmpty(String s) {
-        return s != null ? s : "";
+    private static String str(Map<String, Object> body, String key) {
+        return Optional.ofNullable(body.get(key))
+                .map(Object::toString).map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .orElse(null);
     }
 
-    /** map 中存在 key 时应用字符串值 */
+    private static String nullToEmpty(String s) { return s != null ? s : ""; }
+
     private static void applyStr(Map<String, Object> body, String key, Consumer<String> setter) {
-        Optional.ofNullable(str(body, key)).ifPresent(setter);
+        Optional.ofNullable(body.get(key))
+                .ifPresent(v -> {
+                    String s = v.toString().trim();
+                    if (!s.isEmpty()) {
+                        setter.accept(s);
+                    }
+                });
     }
 
     private static String col(String[] cols, int idx) {
-        if (idx >= cols.length) return null;
-        String s = cols[idx].trim();
-        return s.isEmpty() ? null : s;
+        return idx < cols.length ? Optional.of(cols[idx].trim()).filter(s -> !s.isEmpty()).orElse(null) : null;
     }
 
     private static String esc(String s) {
-        if (s == null || s.isEmpty()) return "";
-        return "\"" + s.replace("\"", "\"\"") + "\"";
+        return (s == null || s.isEmpty()) ? "" : "\"" + s.replace("\"", "\"\"") + "\"";
+    }
+
+    private static int intVal(Map<String, Object> body) {
+        return Optional.ofNullable(body.get("sort_order"))
+                .filter(v -> v instanceof Number)
+                .map(v -> ((Number) v).intValue())
+                .orElse(0);
     }
 }