|
@@ -315,7 +315,7 @@ public class BrandRecommendService {
|
|
|
|
|
|
|
|
public Map<String, Object> importBrandsCsv(String csv) {
|
|
public Map<String, Object> importBrandsCsv(String csv) {
|
|
|
List<String[]> rows = parseCsv(csv);
|
|
List<String[]> rows = parseCsv(csv);
|
|
|
- Map<String, Integer> colIdx = getIntegerMap(rows);
|
|
|
|
|
|
|
+ Map<String, Integer> colIdx = buildColIdx(rows, BRAND_CSV_COLS);
|
|
|
|
|
|
|
|
int success = 0, skipped = 0, failed = 0;
|
|
int success = 0, skipped = 0, failed = 0;
|
|
|
for (int i = 1; i < rows.size(); i++) {
|
|
for (int i = 1; i < rows.size(); i++) {
|
|
@@ -348,27 +348,44 @@ public class BrandRecommendService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@NotNull
|
|
@NotNull
|
|
|
- private static Map<String, Integer> getIntegerMap(List<String[]> rows) {
|
|
|
|
|
|
|
+ private static Map<String, Integer> buildColIdx(List<String[]> rows, List<String> expectedHeaders) {
|
|
|
if (rows.isEmpty()) {
|
|
if (rows.isEmpty()) {
|
|
|
- throw new IllegalArgumentException("CSV format incorrect: expected header " + BRANDS_CSV_HEADER);
|
|
|
|
|
|
|
+ throw new IllegalArgumentException("CSV格式不正确,期望表头: " + String.join(",", expectedHeaders));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Build header -> index map from the first row
|
|
|
|
|
String[] header = rows.getFirst();
|
|
String[] header = rows.getFirst();
|
|
|
Map<String, Integer> colIdx = new LinkedHashMap<>();
|
|
Map<String, Integer> colIdx = new LinkedHashMap<>();
|
|
|
for (int i = 0; i < header.length; i++) {
|
|
for (int i = 0; i < header.length; i++) {
|
|
|
- colIdx.put(header[i].trim().replace("\"", ""), i);
|
|
|
|
|
|
|
+ colIdx.put(normalizeHeader(header[i]), i);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Verify all expected headers are present
|
|
|
|
|
- for (String h : BRAND_CSV_COLS) {
|
|
|
|
|
|
|
+ for (String h : expectedHeaders) {
|
|
|
if (!colIdx.containsKey(h)) {
|
|
if (!colIdx.containsKey(h)) {
|
|
|
- throw new IllegalArgumentException("CSV missing column: " + h);
|
|
|
|
|
|
|
+ throw new IllegalArgumentException("CSV缺少列: " + h + ",实际表头: " + String.join(", ", header));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
return colIdx;
|
|
return colIdx;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** Normalize a CSV header cell: strip BOM, invisible chars, quotes, and trim. */
|
|
|
|
|
+ private static String normalizeHeader(String raw) {
|
|
|
|
|
+ if (raw == null) return "";
|
|
|
|
|
+ String s = raw;
|
|
|
|
|
+ // Strip BOM anywhere (not just at start of file — can appear on first cell)
|
|
|
|
|
+ s = s.replace("", "");
|
|
|
|
|
+ // Strip zero-width characters that Excel/WPS may inject
|
|
|
|
|
+ s = s.replace("", ""); // zero-width space
|
|
|
|
|
+ s = s.replace("", ""); // zero-width non-joiner
|
|
|
|
|
+ s = s.replace("", ""); // zero-width joiner
|
|
|
|
|
+ s = s.replace("", ""); // BOM / zero-width no-break space
|
|
|
|
|
+ s = s.replace(" ", " "); // non-breaking space → normal space
|
|
|
|
|
+ // Strip other common invisible control chars except space
|
|
|
|
|
+ s = s.replaceAll("[\\p{Cntrl}&&[^\t]]", "");
|
|
|
|
|
+ // Strip surrounding quotes
|
|
|
|
|
+ s = s.replace("\"", "").replace("'", "");
|
|
|
|
|
+ return s.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
public String exportBrandsCsv(Set<Integer> ids) {
|
|
public String exportBrandsCsv(Set<Integer> ids) {
|
|
|
List<Brand> brands = ids.isEmpty() ? List.of() : brandRepository.findAllById(ids);
|
|
List<Brand> brands = ids.isEmpty() ? List.of() : brandRepository.findAllById(ids);
|
|
|
StringBuilder sb = new StringBuilder(BRANDS_CSV_HEADER).append("\n");
|
|
StringBuilder sb = new StringBuilder(BRANDS_CSV_HEADER).append("\n");
|
|
@@ -395,7 +412,7 @@ public class BrandRecommendService {
|
|
|
|
|
|
|
|
public Map<String, Object> importRulesCsv(String csv) {
|
|
public Map<String, Object> importRulesCsv(String csv) {
|
|
|
List<String[]> rows = parseCsv(csv);
|
|
List<String[]> rows = parseCsv(csv);
|
|
|
- Map<String, Integer> colIdx = getStringIntegerMap(rows);
|
|
|
|
|
|
|
+ Map<String, Integer> colIdx = buildColIdx(rows, RULES_CSV_COLS);
|
|
|
|
|
|
|
|
int success = 0, skipped = 0, failed = 0;
|
|
int success = 0, skipped = 0, failed = 0;
|
|
|
for (int i = 1; i < rows.size(); i++) {
|
|
for (int i = 1; i < rows.size(); i++) {
|
|
@@ -426,28 +443,6 @@ public class BrandRecommendService {
|
|
|
return Map.of("ok", true, "success", success, "skipped", skipped, "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) {
|
|
public String exportRulesCsv(Set<Integer> ids) {
|
|
|
List<BrandRecommendRule> rules = ids.isEmpty() ? List.of() : ruleRepository.findAllById(ids);
|
|
List<BrandRecommendRule> rules = ids.isEmpty() ? List.of() : ruleRepository.findAllById(ids);
|
|
|
Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
|
|
Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
|
|
@@ -487,7 +482,8 @@ public class BrandRecommendService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Parse CSV content into rows, respecting quoted fields.
|
|
/** Parse CSV content into rows, respecting quoted fields.
|
|
|
- * Newlines inside "..." quoted fields do NOT split rows. */
|
|
|
|
|
|
|
+ * Newlines inside "..." quoted fields do NOT split rows.
|
|
|
|
|
+ * Auto-detects delimiter from header row (comma, tab, semicolon). */
|
|
|
private static List<String[]> parseCsv(String csv) {
|
|
private static List<String[]> parseCsv(String csv) {
|
|
|
List<String[]> rows = new ArrayList<>();
|
|
List<String[]> rows = new ArrayList<>();
|
|
|
// Strip UTF-8 BOM if present (Excel adds it when saving UTF-8 CSV)
|
|
// Strip UTF-8 BOM if present (Excel adds it when saving UTF-8 CSV)
|
|
@@ -526,18 +522,49 @@ public class BrandRecommendService {
|
|
|
lines.add(lineBuf.toString());
|
|
lines.add(lineBuf.toString());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Filter empty/comment lines
|
|
|
|
|
+ List<String> dataLines = new ArrayList<>();
|
|
|
for (String line : lines) {
|
|
for (String line : lines) {
|
|
|
String trimmed = line.trim();
|
|
String trimmed = line.trim();
|
|
|
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
|
|
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- rows.add(parseCsvRow(trimmed));
|
|
|
|
|
|
|
+ dataLines.add(trimmed);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dataLines.isEmpty()) {
|
|
|
|
|
+ return rows;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Auto-detect delimiter from header line
|
|
|
|
|
+ char delimiter = detectDelimiter(dataLines.getFirst());
|
|
|
|
|
+
|
|
|
|
|
+ for (String line : dataLines) {
|
|
|
|
|
+ rows.add(parseCsvRow(line, delimiter));
|
|
|
}
|
|
}
|
|
|
return rows;
|
|
return rows;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** Detect the most likely delimiter from a header row. */
|
|
|
|
|
+ private static char detectDelimiter(String headerLine) {
|
|
|
|
|
+ int[] counts = new int[256];
|
|
|
|
|
+ boolean inQ = false;
|
|
|
|
|
+ for (int i = 0; i < headerLine.length(); i++) {
|
|
|
|
|
+ char ch = headerLine.charAt(i);
|
|
|
|
|
+ if (ch == '"') inQ = !inQ;
|
|
|
|
|
+ if (!inQ && (ch == ',' || ch == '\t' || ch == ';' || ch == '|')) {
|
|
|
|
|
+ counts[ch]++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ char best = ',';
|
|
|
|
|
+ int max = counts[','];
|
|
|
|
|
+ if (counts['\t'] > max) { best = '\t'; max = counts['\t']; }
|
|
|
|
|
+ if (counts[';'] > max) { best = ';'; max = counts[';']; }
|
|
|
|
|
+ if (counts['|'] > max) { best = '|'; max = counts['|']; }
|
|
|
|
|
+ return best;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** Parse a single CSV row into cells, respecting "..." quoted fields */
|
|
/** Parse a single CSV row into cells, respecting "..." quoted fields */
|
|
|
- private static String[] parseCsvRow(String line) {
|
|
|
|
|
|
|
+ private static String[] parseCsvRow(String line, char delimiter) {
|
|
|
List<String> cells = new ArrayList<>();
|
|
List<String> cells = new ArrayList<>();
|
|
|
StringBuilder sb = new StringBuilder();
|
|
StringBuilder sb = new StringBuilder();
|
|
|
boolean inQuotes = false;
|
|
boolean inQuotes = false;
|
|
@@ -557,7 +584,7 @@ public class BrandRecommendService {
|
|
|
} else {
|
|
} else {
|
|
|
if (ch == '"') {
|
|
if (ch == '"') {
|
|
|
inQuotes = true;
|
|
inQuotes = true;
|
|
|
- } else if (ch == ',') {
|
|
|
|
|
|
|
+ } else if (ch == delimiter) {
|
|
|
cells.add(sb.toString().trim());
|
|
cells.add(sb.toString().trim());
|
|
|
sb.setLength(0);
|
|
sb.setLength(0);
|
|
|
} else {
|
|
} else {
|