From 37f52097847f9025589c79034be2454432c55433 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期五, 02 十月 2026 10:12:27 +0800
Subject: [PATCH] docs(交接): 记录关机前开发服务已停止

---
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java |  430 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 430 insertions(+), 0 deletions(-)

diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
new file mode 100644
index 0000000..1c686ee
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
@@ -0,0 +1,430 @@
+package com.trafficaudit.holiday.service;
+
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.nio.file.Files;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+@Slf4j
+@Service
+public class HolidayBulletinService {
+
+    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+    private static final BigDecimal EPSILON = new BigDecimal("0.0000001");
+
+    @Value("${holiday.bulletin-template:docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ユā鏉�.docx}")
+    private String bulletinTemplatePath;
+
+    private final HolidayStatService statService;
+    private static final ThreadLocal<Document> CURRENT_DOCUMENT = new ThreadLocal<>();
+
+    public HolidayBulletinService(HolidayStatService statService) {
+        this.statService = statService;
+    }
+
+    public byte[] generate(LocalDate date) {
+        DailyMetrics current = statService.findMetricsByDate(date);
+        if (current == null) {
+            throw new IllegalArgumentException("鎵�閫夋棩鏈熶笉鍦ㄥ凡閰嶇疆鐨勮妭鍋囨棩鑼冨洿鍐�");
+        }
+        DailyMetrics previousDay = current.getDayIndex() > 1
+                ? statService.dailyMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex() - 1) : null;
+        DailyMetrics previousYear = statService.dailyMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
+        DailyMetrics currentCumulative = statService.cumulativeMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex());
+        DailyMetrics previousYearCumulative = statService.cumulativeMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
+
+        if (current.getTotalPersonFlow() == null) {
+            throw new IllegalArgumentException("鎵�閫夋棩鏈熺殑鍏ㄧぞ浼氳法鍖哄煙浜哄憳娴佸姩閲忔病鏈夎冻澶熸暟鎹紝鏃犳硶鐢熸垚蹇姤");
+        }
+
+        String currentTotal = formatNumber(current.getTotalPersonFlow());
+        String cumulativeTotalYi = formatNumber(currentCumulative.getTotalPersonFlow() == null
+                ? null : HolidayNumberUtils.divide(currentCumulative.getTotalPersonFlow(), BigDecimal.valueOf(10000)));
+        String ringRatioClause = previousDay == null ? "" : "锛岀幆姣�" + ratioPhrase(
+                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow());
+        String paragraph1 = String.format("%d鏈�%d鏃ワ紙鍋囨湡绗�%d澶╋級锛屽叏鐪佸叏绀句細璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺%s涓囦汉娆�%s锛屾瘮2025骞村悓鏈�%s銆�",
+                date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal,
+                ringRatioClause,
+                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow()));
+        String paragraph2 = String.format("鍋囨湡浠ユ潵锛屽叏鐪佸叏绀句細璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺绱%s浜夸汉娆★紝姣�2025骞村悓鏈�%s銆�",
+                cumulativeTotalYi,
+                ratioPhrase(currentCumulative.getTotalPersonFlow(),
+                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
+
+        File template = resolveTemplateFile();
+        if (!template.exists() || !template.isFile()) {
+            throw new IllegalArgumentException("蹇姤妯℃澘鏂囦欢涓嶅瓨鍦�: " + template.getAbsolutePath());
+        }
+
+        try {
+            byte[] templateBytes;
+            try (InputStream in = new FileInputStream(template)) {
+                templateBytes = readAll(in);
+            }
+            byte[] documentXml = readZipEntry(templateBytes, "word/document.xml");
+            Document document = parseDocument(documentXml);
+            CURRENT_DOCUMENT.set(document);
+            try {
+                replaceParagraph(document, text -> text.startsWith("10鏈�") && text.contains("鍋囨湡绗�"), paragraph1);
+                replaceParagraph(document, text -> text.startsWith("鍋囨湡浠ユ潵"), paragraph2);
+                fillTable(document, current, previousDay, previousYear, currentCumulative, previousYearCumulative);
+                byte[] replacedXml = serializeDocument(document);
+                return replaceZipEntry(templateBytes, "word/document.xml", replacedXml);
+            } finally {
+                CURRENT_DOCUMENT.remove();
+            }
+        } catch (Exception e) {
+            throw new IllegalArgumentException("蹇姤鐢熸垚澶辫触: " + e.getMessage(), e);
+        }
+    }
+
+    private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay,
+                           DailyMetrics previousYear, DailyMetrics currentCumulative,
+                           DailyMetrics previousYearCumulative) {
+        Element table = firstElement(document, "tbl");
+        if (table == null) {
+            throw new IllegalArgumentException("蹇姤妯℃澘涓壘涓嶅埌鏁版嵁琛�");
+        }
+        writeTableRow(table, 2, new String[]{
+                number(current.getNonBusinessPersonFlow()),
+                ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow()),
+                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow()),
+                "鈥�",
+                number(currentCumulative.getNonBusinessPersonFlow()),
+                ratio(currentCumulative.getNonBusinessPersonFlow(),
+                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow()),
+                "鈥�"
+        });
+        writeTableRow(table, 3, new String[]{
+                number(current.getRoad()),
+                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad()),
+                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad()),
+                "鈥�",
+                number(currentCumulative.getRoad()),
+                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad()),
+                "鈥�"
+        });
+        writeTableRow(table, 4, new String[]{
+                number(current.getRoadPersonFlow()),
+                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow()),
+                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow()),
+                "鈥�",
+                number(currentCumulative.getRoadPersonFlow()),
+                ratio(currentCumulative.getRoadPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
+                "鈥�"
+        });
+        writeTableRow(table, 5, transportRow(current.getRail(), previousDay == null ? null : previousDay.getRail(),
+                previousYear == null ? null : previousYear.getRail(), currentCumulative.getRail(),
+                previousYearCumulative == null ? null : previousYearCumulative.getRail()));
+        writeTableRow(table, 6, transportRow(current.getCivilAviation(), previousDay == null ? null : previousDay.getCivilAviation(),
+                previousYear == null ? null : previousYear.getCivilAviation(), currentCumulative.getCivilAviation(),
+                previousYearCumulative == null ? null : previousYearCumulative.getCivilAviation()));
+        writeTableRow(table, 7, transportRow(current.getWater(), previousDay == null ? null : previousDay.getWater(),
+                previousYear == null ? null : previousYear.getWater(), currentCumulative.getWater(),
+                previousYearCumulative == null ? null : previousYearCumulative.getWater()));
+        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
+                previousYear == null ? null : previousYear.getTotalPersonFlow(), currentCumulative.getTotalPersonFlow(),
+                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
+    }
+
+    private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
+                                  BigDecimal currentCumulative, BigDecimal previousYearCumulative) {
+        return new String[]{
+                number(current),
+                ratio(current, previousDay),
+                ratio(current, previousYear),
+                "鈥�",
+                number(currentCumulative),
+                ratio(currentCumulative, previousYearCumulative),
+                "鈥�"
+        };
+    }
+
+    private void writeTableRow(Element table, int rowIndex, String[] values) {
+        List<Element> rows = childElements(table, "tr");
+        if (rowIndex >= rows.size()) return;
+        Element row = rows.get(rowIndex);
+        List<Element> cells = cellRefs(row);
+        int logicalColumn = 0;
+        for (Element cell : cells) {
+            int span = gridSpan(cell);
+            if (logicalColumn >= 2 && logicalColumn < 9) {
+                int valueIndex = logicalColumn - 2;
+                if (valueIndex < values.length) {
+                    setCellText(cell, values[valueIndex]);
+                }
+            }
+            logicalColumn += span;
+        }
+    }
+
+    private List<Element> cellRefs(Element row) {
+        List<Element> cells = new ArrayList<>();
+        for (Element child : childElements(row, "tc")) cells.add(child);
+        return cells;
+    }
+
+    private int gridSpan(Element cell) {
+        Element tcPr = firstChildElement(cell, "tcPr");
+        if (tcPr == null) return 1;
+        Element span = firstChildElement(tcPr, "gridSpan");
+        if (span == null) return 1;
+        String value = span.getAttributeNS(WORD_NS, "val");
+        try {
+            return Integer.parseInt(value);
+        } catch (Exception e) {
+            return 1;
+        }
+    }
+
+    private void setCellText(Element cell, String text) {
+        Element tcPr = firstChildElement(cell, "tcPr");
+        Element templateParagraph = firstChildElement(cell, "p");
+        Element paragraph = createElement("p");
+        if (templateParagraph != null) {
+            Element pPr = firstChildElement(templateParagraph, "pPr");
+            if (pPr != null) paragraph.appendChild(pPr.cloneNode(true));
+        }
+        Element run = createElement("r");
+        Element t = createElement("t");
+        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
+        t.setTextContent(text == null ? "" : text);
+        run.appendChild(t);
+        paragraph.appendChild(run);
+
+        List<Node> removals = new ArrayList<>();
+        Node child = cell.getFirstChild();
+        while (child != null) {
+            Node next = child.getNextSibling();
+            if (child instanceof Element && "p".equals(localName(child))) {
+                removals.add(child);
+            }
+            child = next;
+        }
+        for (Node node : removals) cell.removeChild(node);
+        if (tcPr != null && tcPr.getNextSibling() != null) {
+            cell.insertBefore(paragraph, tcPr.getNextSibling());
+        } else if (tcPr != null) {
+            cell.appendChild(paragraph);
+        } else {
+            cell.insertBefore(paragraph, cell.getFirstChild());
+        }
+    }
+
+    private void replaceParagraph(Document document, ParagraphMatcher matcher, String replacement) {
+        NodeList paragraphs = document.getElementsByTagNameNS(WORD_NS, "p");
+        for (int i = 0; i < paragraphs.getLength(); i++) {
+            Element paragraph = (Element) paragraphs.item(i);
+            String text = paragraphText(paragraph);
+            if (matcher.matches(text)) {
+                setParagraphText(paragraph, replacement);
+                return;
+            }
+        }
+        throw new IllegalArgumentException("蹇姤妯℃澘缂哄皯鍙浛鎹㈡钀�: " + replacement);
+    }
+
+    private void setParagraphText(Element paragraph, String text) {
+        Element pPr = firstChildElement(paragraph, "pPr");
+        Element firstRun = firstChildElement(paragraph, "r");
+        Element rPr = firstRun == null ? null : firstChildElement(firstRun, "rPr");
+        List<Node> removals = new ArrayList<>();
+        Node child = paragraph.getFirstChild();
+        while (child != null) {
+            Node next = child.getNextSibling();
+            if (child != pPr) removals.add(child);
+            child = next;
+        }
+        for (Node node : removals) paragraph.removeChild(node);
+
+        Element run = createElement("r");
+        if (rPr != null) run.appendChild(rPr.cloneNode(true));
+        Element t = createElement("t");
+        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
+        t.setTextContent(text);
+        run.appendChild(t);
+        paragraph.appendChild(run);
+    }
+
+    private String paragraphText(Element paragraph) {
+        StringBuilder builder = new StringBuilder();
+        NodeList texts = paragraph.getElementsByTagNameNS(WORD_NS, "t");
+        for (int i = 0; i < texts.getLength(); i++) {
+            builder.append(texts.item(i).getTextContent());
+        }
+        return builder.toString();
+    }
+
+    private String ratioPhrase(BigDecimal current, BigDecimal base) {
+        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "鏃犲彲姣旀暟鎹�";
+        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
+        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
+        if (percent == null || percent.abs().compareTo(EPSILON) < 0) return "鎸佸钩";
+        return (percent.signum() > 0 ? "澧為暱" : "涓嬮檷") + HolidayNumberUtils.format1(percent.abs()) + "%";
+    }
+
+    private String ratio(BigDecimal current, BigDecimal base) {
+        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "鈥�";
+        return ratioPhrase(current, base);
+    }
+
+    private String number(BigDecimal value) {
+        return value == null ? "鈥�" : formatNumber(value);
+    }
+
+    private String formatNumber(BigDecimal value) {
+        if (value == null) return "";
+        return HolidayNumberUtils.format2(value);
+    }
+
+    private File resolveTemplateFile() {
+        List<File> candidates = new ArrayList<>();
+        File configured = new File(bulletinTemplatePath);
+        candidates.add(configured);
+        if (!configured.isAbsolute()) {
+            candidates.add(new File(System.getProperty("user.dir"), bulletinTemplatePath));
+            candidates.add(new File(System.getProperty("user.dir"), "../" + bulletinTemplatePath));
+        }
+        for (File candidate : candidates) {
+            try {
+                if (candidate.exists() && candidate.isFile()) {
+                    return candidate.getCanonicalFile();
+                }
+            } catch (Exception ignored) {
+                // Keep checking.
+            }
+        }
+        return configured;
+    }
+
+    private byte[] readZipEntry(byte[] zipBytes, String entryName) throws Exception {
+        try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
+            ZipEntry entry;
+            while ((entry = zin.getNextEntry()) != null) {
+                if (entryName.equals(entry.getName())) {
+                    return readAll(zin);
+                }
+            }
+        }
+        throw new IllegalArgumentException("妯℃澘涓己灏� " + entryName);
+    }
+
+    private byte[] replaceZipEntry(byte[] zipBytes, String entryName, byte[] replacement) throws Exception {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes));
+             ZipOutputStream zout = new ZipOutputStream(out)) {
+            ZipEntry entry;
+            byte[] buffer = new byte[8192];
+            while ((entry = zin.getNextEntry()) != null) {
+                ZipEntry target = new ZipEntry(entry.getName());
+                zout.putNextEntry(target);
+                if (entryName.equals(entry.getName())) {
+                    zout.write(replacement);
+                } else {
+                    int length;
+                    while ((length = zin.read(buffer)) > 0) {
+                        zout.write(buffer, 0, length);
+                    }
+                }
+                zout.closeEntry();
+            }
+        }
+        return out.toByteArray();
+    }
+
+    private Document parseDocument(byte[] xml) throws Exception {
+        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+        factory.setNamespaceAware(true);
+        return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
+    }
+
+    private byte[] serializeDocument(Document document) throws Exception {
+        Transformer transformer = TransformerFactory.newInstance().newTransformer();
+        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+        transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        transformer.transform(new DOMSource(document), new StreamResult(out));
+        return out.toByteArray();
+    }
+
+    private Element firstElement(Document document, String localName) {
+        NodeList list = document.getElementsByTagNameNS(WORD_NS, localName);
+        return list.getLength() == 0 ? null : (Element) list.item(0);
+    }
+
+    private Element createElement(String localName) {
+        Document document = CURRENT_DOCUMENT.get();
+        if (document == null) {
+            throw new IllegalStateException("Word document is not initialized");
+        }
+        return document.createElementNS(WORD_NS, "w:" + localName);
+    }
+
+    private List<Element> childElements(Element parent, String localName) {
+        List<Element> result = new ArrayList<>();
+        Node child = parent.getFirstChild();
+        while (child != null) {
+            if (child instanceof Element && localName.equals(localName(child))) {
+                result.add((Element) child);
+            }
+            child = child.getNextSibling();
+        }
+        return result;
+    }
+
+    private Element firstChildElement(Element parent, String localName) {
+        Node child = parent.getFirstChild();
+        while (child != null) {
+            if (child instanceof Element && localName.equals(localName(child))) {
+                return (Element) child;
+            }
+            child = child.getNextSibling();
+        }
+        return null;
+    }
+
+    private String localName(Node node) {
+        return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName();
+    }
+
+    private byte[] readAll(InputStream in) throws Exception {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        byte[] buffer = new byte[8192];
+        int length;
+        while ((length = in.read(buffer)) > 0) {
+            out.write(buffer, 0, length);
+        }
+        return out.toByteArray();
+    }
+
+    private interface ParagraphMatcher {
+        boolean matches(String text);
+    }
+}

--
Gitblit v1.9.1