Răsfoiți Sursa

导入和导出更新

liuchengsen 3 săptămâni în urmă
părinte
comite
e4430fc329

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

@@ -4,6 +4,7 @@ import com.pharmacopoeia.entity.Brand;
 import com.pharmacopoeia.entity.BrandRecommendRule;
 import com.pharmacopoeia.repository.BrandRecommendRuleRepository;
 import com.pharmacopoeia.repository.BrandRepository;
+import org.jetbrains.annotations.NotNull;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -27,16 +28,42 @@ public class BrandRecommendService {
 
     // ==================== 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"
-    );
+    // Chinese CSV headers -> Java field name mapping
+    private static final Map<String, String> CSV_HEADER_TO_FIELD = new LinkedHashMap<>() {{
+        put("品牌名称", "name");
+        put("功能主治", "function_indication");
+        put("用法用量", "usage_dosage");
+        put("禁忌", "contraindication");
+        put("成分", "ingredients");
+        put("性状", "properties");
+        put("规格", "specification");
+        put("不良反应", "adverse_reactions");
+        put("注意事项", "precautions");
+        put("执行标准", "execution_standard");
+        put("贮藏", "storage");
+        put("跳转链接", "jump_url");
+        put("描述", "description");
+        put("排序", "sort_order");
+    }};
+
+    private static final List<String> BRAND_CSV_COLS = new ArrayList<>(CSV_HEADER_TO_FIELD.keySet());
+    
+    private static final List<String> BRAND_FIELD_NAMES = new ArrayList<>(CSV_HEADER_TO_FIELD.values());
     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()
+    // Rules CSV Chinese headers -> field name
+    private static final Map<String, String> RULES_CSV_HEADER_TO_FIELD = new LinkedHashMap<>() {{
+        put("关键词", "keyword");
+        put("品牌ID", "brand_id");
+        put("品牌名称", "brand_name");
+        put("层级", "tier");
+        put("是否启用", "is_active");
+    }};
+    private static final List<String> RULES_CSV_COLS = new ArrayList<>(RULES_CSV_HEADER_TO_FIELD.keySet());
+    private static final String RULES_CSV_HEADER = String.join(",", RULES_CSV_COLS);
+
+    // non-name, non-sort_order columns (field names for internal use)
+    private static final List<String> BRAND_TEXT_COLS = BRAND_FIELD_NAMES.stream()
             .filter(c -> !"name".equals(c) && !"sort_order".equals(c))
             .toList();
 
@@ -189,7 +216,7 @@ public class BrandRecommendService {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("id", b.getId());
         m.put("name", b.getName());
-        BRAND_CSV_COLS.stream().filter(c -> !"name".equals(c))
+        BRAND_FIELD_NAMES.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());
@@ -287,38 +314,59 @@ public class BrandRecommendService {
     // ==================== 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);
-        }
+        List<String[]> rows = parseCsv(csv);
+        Map<String, Integer> colIdx = getIntegerMap(rows);
 
-        int success = 0, failed = 0;
-        for (int i = 1; i < lines.size(); i++) {
-            String[] cols = lines.get(i).split(",", BRAND_CSV_COLS.size());
+        int success = 0, skipped = 0, failed = 0;
+        for (int i = 1; i < rows.size(); i++) {
+            String[] cols = rows.get(i);
             try {
-                String name = cols[0].trim();
-                if (name.isEmpty() || brandRepository.existsByNameAndIdNot(name, null)) { failed++; continue; }
+                String name = cell(cols, colIdx, "品牌名称");
+                if (name == null || name.isEmpty()) { failed++; continue; }
+                if (brandRepository.existsByNameAndIdNot(name, null)) { skipped++; continue; }
                 brandRepository.save(Brand.builder()
                         .name(name)
-                        .functionIndication(col(cols, 1))
-                        .usageDosage(col(cols, 2))
-                        .contraindication(col(cols, 3))
-                        .ingredients(col(cols, 4))
-                        .properties(col(cols, 5))
-                        .specification(col(cols, 6))
-                        .adverseReactions(col(cols, 7))
-                        .precautions(col(cols, 8))
-                        .executionStandard(col(cols, 9))
-                        .storage(col(cols, 10))
-                        .jumpUrl(col(cols, 11))
-                        .description(col(cols, 12))
-                        .sortOrder(cols.length > 13 ? Integer.parseInt(cols[13].trim()) : 0)
+                        .functionIndication(cell(cols, colIdx, "功能主治"))
+                        .usageDosage(cell(cols, colIdx, "用法用量"))
+                        .contraindication(cell(cols, colIdx, "禁忌"))
+                        .ingredients(cell(cols, colIdx, "成分"))
+                        .properties(cell(cols, colIdx, "性状"))
+                        .specification(cell(cols, colIdx, "规格"))
+                        .adverseReactions(cell(cols, colIdx, "不良反应"))
+                        .precautions(cell(cols, colIdx, "注意事项"))
+                        .executionStandard(cell(cols, colIdx, "执行标准"))
+                        .storage(cell(cols, colIdx, "贮藏"))
+                        .jumpUrl(cell(cols, colIdx, "跳转链接"))
+                        .description(cell(cols, colIdx, "描述"))
+                        .sortOrder(cellInt(cols, colIdx, "排序"))
                         .isActive(true)
                         .build());
                 success++;
             } catch (Exception e) { failed++; }
         }
-        return Map.of("ok", true, "success", success, "failed", failed);
+        return Map.of("ok", true, "success", success, "skipped", skipped, "failed", failed);
+    }
+
+    @NotNull
+    private static Map<String, Integer> getIntegerMap(List<String[]> rows) {
+        if (rows.isEmpty()) {
+            throw new IllegalArgumentException("CSV format incorrect: expected header " + BRANDS_CSV_HEADER);
+        }
+
+        // Build header -> index map from the first row
+        String[] header = rows.getFirst();
+        Map<String, Integer> colIdx = new LinkedHashMap<>();
+        for (int i = 0; i < header.length; i++) {
+            colIdx.put(header[i].trim().replace("\"", ""), i);
+        }
+
+        // Verify all expected headers are present
+        for (String h : BRAND_CSV_COLS) {
+            if (!colIdx.containsKey(h)) {
+                throw new IllegalArgumentException("CSV missing column: " + h);
+            }
+        }
+        return colIdx;
     }
 
     public String exportBrandsCsv(Set<Integer> ids) {
@@ -346,34 +394,58 @@ public class BrandRecommendService {
     }
 
     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);
-        }
+        List<String[]> rows = parseCsv(csv);
+        Map<String, Integer> colIdx = getStringIntegerMap(rows);
 
-        int success = 0, failed = 0;
-        for (int i = 1; i < lines.size(); i++) {
-            String[] cols = lines.get(i).split(",", 5);
+        int success = 0, skipped = 0, failed = 0;
+        for (int i = 1; i < rows.size(); i++) {
+            String[] cols = rows.get(i);
             try {
-                String kw = cols[0].trim();
-                if (kw.isEmpty()) { failed++; continue; }
+                String kw = cell(cols, colIdx, "关键词");
+                if (kw == null || kw.isEmpty()) { failed++; continue; }
+                if (ruleRepository.existsByKeywordAndIdNot(kw, null)) { skipped++; continue; }
 
-                int brandId = cols.length > 1 ? parseIntOrDefault(cols[1], 0) : 0;
-                String brandName = cols.length > 2 ? cols[2].trim() : "";
+                int brandId = cellInt(cols, colIdx, "品牌ID");
+                String brandName = cell(cols, colIdx, "品牌名称");
                 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; }
+                int tier = cellInt(cols, colIdx, "层级");
+                if (tier <= 0) {
+                    tier = 1;
+                }
+                boolean isActive = !"false".equalsIgnoreCase(cell(cols, colIdx, "是否启用"));
                 ruleRepository.save(BrandRecommendRule.builder()
-                        .keyword(kw).brandId(resolvedId).tier(tier).isActive(true).build());
+                        .keyword(kw).brandId(resolvedId).tier(tier).isActive(isActive).build());
                 success++;
             } catch (Exception e) { failed++; }
         }
-        return Map.of("ok", true, "success", success, "failed", failed);
+        return Map.of("ok", true, "success", success, "skipped", skipped, "failed", failed);
+    }
+
+    @NotNull
+    private static Map<String, Integer> getStringIntegerMap(List<String[]> rows) {
+        if (rows.isEmpty()) {
+            throw new IllegalArgumentException("CSV格式不正确,期望表头: " + RULES_CSV_HEADER);
+        }
+
+        // Build header -> index map, stripping quotes/spaces from header names
+        String[] header = rows.getFirst();
+        Map<String, Integer> colIdx = new LinkedHashMap<>();
+        for (int i = 0; i < header.length; i++) {
+            colIdx.put(header[i].trim().replace("\"", ""), i);
+        }
+
+        // Verify all expected headers are present
+        for (String h : RULES_CSV_COLS) {
+            if (!colIdx.containsKey(h)) {
+                throw new IllegalArgumentException("CSV缺少列: " + h);
+            }
+        }
+        return colIdx;
     }
 
     public String exportRulesCsv(Set<Integer> ids) {
@@ -382,9 +454,9 @@ public class BrandRecommendService {
         Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
                 .collect(Collectors.toMap(Brand::getId, Brand::getName));
         StringBuilder sb = new StringBuilder(RULES_CSV_HEADER).append("\n");
-        rules.forEach(r -> sb.append(r.getKeyword()).append(",")
+        rules.forEach(r -> sb.append(esc(r.getKeyword())).append(",")
                 .append(r.getBrandId()).append(",")
-                .append(brandNames.getOrDefault(r.getBrandId(), "")).append(",")
+                .append(esc(brandNames.getOrDefault(r.getBrandId(), ""))).append(",")
                 .append(r.getTier()).append(",")
                 .append(r.getIsActive()).append("\n"));
         return sb.toString();
@@ -414,11 +486,83 @@ public class BrandRecommendService {
         );
     }
 
-    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());
+    /** Parse CSV content into rows, respecting quoted fields.
+     *  Newlines inside "..." quoted fields do NOT split rows. */
+    private static List<String[]> parseCsv(String csv) {
+        List<String[]> rows = new ArrayList<>();
+        csv = csv.replace("\r\n", "\n").replace("\r", "\n");
+
+        // Split into logical lines: \n inside a quoted field stays part of the cell value
+        List<String> lines = new ArrayList<>();
+        StringBuilder lineBuf = new StringBuilder();
+        boolean inQuotes = false;
+        for (int i = 0; i < csv.length(); i++) {
+            char ch = csv.charAt(i);
+            if (inQuotes) {
+                lineBuf.append(ch);
+                if (ch == '"' && i + 1 < csv.length() && csv.charAt(i + 1) == '"') {
+                    lineBuf.append('"');
+                    i++;
+                } else if (ch == '"') {
+                    inQuotes = false;
+                }
+            } else {
+                if (ch == '"') {
+                    inQuotes = true;
+                    lineBuf.append(ch);
+                } else if (ch == '\n') {
+                    lines.add(lineBuf.toString());
+                    lineBuf.setLength(0);
+                } else {
+                    lineBuf.append(ch);
+                }
+            }
+        }
+        if (!lineBuf.isEmpty()) {
+            lines.add(lineBuf.toString());
+        }
+
+        for (String line : lines) {
+            String trimmed = line.trim();
+            if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+                continue;
+            }
+            rows.add(parseCsvRow(trimmed));
+        }
+        return rows;
+    }
+
+    /** Parse a single CSV row into cells, respecting "..." quoted fields */
+    private static String[] parseCsvRow(String line) {
+        List<String> cells = new ArrayList<>();
+        StringBuilder sb = new StringBuilder();
+        boolean inQuotes = false;
+        for (int i = 0; i < line.length(); i++) {
+            char ch = line.charAt(i);
+            if (inQuotes) {
+                if (ch == '"') {
+                    if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
+                        sb.append('"');
+                        i++;
+                    } else {
+                        inQuotes = false;
+                    }
+                } else {
+                    sb.append(ch);
+                }
+            } else {
+                if (ch == '"') {
+                    inQuotes = true;
+                } else if (ch == ',') {
+                    cells.add(sb.toString().trim());
+                    sb.setLength(0);
+                } else {
+                    sb.append(ch);
+                }
+            }
+        }
+        cells.add(sb.toString().trim());
+        return cells.toArray(new String[0]);
     }
 
     private static String require(Map<String, Object> body) {
@@ -429,10 +573,6 @@ public class BrandRecommendService {
         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;
@@ -489,8 +629,25 @@ public class BrandRecommendService {
                 });
     }
 
-    private static String col(String[] cols, int idx) {
-        return idx < cols.length ? Optional.of(cols[idx].trim()).filter(s -> !s.isEmpty()).orElse(null) : null;
+    private static String cell(String[] cols, Map<String, Integer> colIdx, String header) {
+        Integer idx = colIdx.get(header);
+        if (idx == null || idx >= cols.length) {
+            return null;
+        }
+        String v = cols[idx].trim();
+        // Strip surrounding double quotes
+        if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
+            v = v.substring(1, v.length() - 1).trim();
+        }
+        return v.isEmpty() ? null : v;
+    }
+
+    private static int cellInt(String[] cols, Map<String, Integer> colIdx, String header) {
+        String v = cell(cols, colIdx, header);
+        if (v == null || v.isEmpty()) {
+            return 0;
+        }
+        try { return Integer.parseInt(v); } catch (NumberFormatException e) { return 0; }
     }
 
     private static String esc(String s) {