2
0

21 Commits a7ebc790a3 ... cd7af78a42

Autor SHA1 Mensagem Data
  skyline cd7af78a42 chore: 联系我们增加工作时间提示;充电状态定时任务补充注释 há 1 mês atrás
  skyline 4cfa120f4e fix: 退款申请防重复提交,避免并发创建多条退款记录 há 1 mês atrás
  skyline 59044113b8 Revert "feat: 登录页底部添加ICP备案号及工信部链接" há 1 mês atrás
  skyline 79b793f051 feat: 登录页底部添加ICP备案号及工信部链接 há 1 mês atrás
  skyline 1c00bc0a19 fix: 回退发票代码排查变更,导出去掉发票代码列 há 1 mês atrás
  skyline 5bc33d3f31 debug: 发票导出增加调试日志,排查blueFapiao反序列化结果 há 1 mês atrás
  skyline 0bcb386a25 Revert: 恢复deploy.sh路径为 /data 目录 há 1 mês atrás
  skyline 5491fba58c fix: 修正deploy.sh服务器路径为实际路径 /home/kym/application há 1 mês atrás
  skyline f2088298e8 fix: 新增InvoiceDetailBlueFapiaoTypeHandle,用TypeReference反序列化蓝票信息 há 1 mês atrás
  skyline 1465653142 fix: FapiaoInfo添加@JsonProperty显式指定JSON字段名,修复JacksonTypeHandler序列化发票代码为空 há 1 mês atrás
  skyline bdcec1d2b6 fix: 发票导出/列表增加开票时间过滤,修复选择时间后仍导出全部的问题 há 1 mês atrás
  skyline c8ec7966c4 fix: 航信发票导出时根据含税总金额反算各项明细,修复空值问题 há 1 mês atrás
  skyline b03ce4fc4e fix: maven改用mvn命令,去掉绝对路径 há 1 mês atrás
  skyline 30462e464d fix: 对齐Jenkins构建流程,修复deploy.sh遗漏的关键步骤 há 1 mês atrás
  skyline c2a6cceb77 fix: charge-front构建前自动安装@dcloudio/uvm,修复uni: not found há 1 mês atrás
  skyline 4c82025746 fix: 证书目录改为服务器实际路径 /data/pem há 1 mês atrás
  skyline 62f87756c0 feat: 将洗车项目的deploy.sh改造为充电项目部署脚本 há 1 mês atrás
  skyline 1e199eea6a fix: 修复发票批量导出因航信重构后items/blueFapiao为null导致NPE的问题 há 1 mês atrás
  skyline b4421a2ac8 fix: 修复结算后定时任务和停止充电推送覆盖订单状态的问题 há 2 meses atrás
  skyline 836fd9174f fix: 修复MyBaseServiceImpl.getSqlStatement中JDK代理类名导致MappedStatement查找失败 há 2 meses atrás
  skyline 6753b83eb9 feat: 站点月表增加手动重新同步功能,支持指定月份重新计算覆盖统计数据 há 2 meses atrás

+ 56 - 0
admin-web/src/views/admin/station/stat/index.vue

@@ -67,8 +67,31 @@
           <SvgIcon name="ele-Search"/>
           查询
         </el-button>
+
+        <el-button v-auth="'stationStatMonth.modify'" plain size="default" type="warning" @click="openResyncDialog">
+          <SvgIcon name="ele-Refresh"/>
+          重新同步
+        </el-button>
       </el-form>
 
+      <el-dialog v-model="resyncDialog.visible" title="重新同步月统计数据" width="400px" :close-on-click-modal="false">
+        <el-form label-width="80px">
+          <el-form-item label="选择月份">
+            <el-date-picker
+              v-model="resyncDialog.month"
+              type="month"
+              value-format="YYYY-MM"
+              placeholder="请选择要重新同步的月份"
+              :disabled-date="(date) => date >= new Date(new Date().getFullYear(), new Date().getMonth(), 1)"
+            />
+          </el-form-item>
+        </el-form>
+        <template #footer>
+          <el-button @click="resyncDialog.visible = false">取消</el-button>
+          <el-button type="primary" :loading="resyncDialog.loading" @click="doResync">确定同步</el-button>
+        </template>
+      </el-dialog>
+
       <el-table
           border
           stripe="stripe"
@@ -210,6 +233,39 @@ onBeforeUnmount(() => {
 
 
 //region 方法区
+
+const resyncDialog = reactive({
+  visible: false,
+  month: '',
+  loading: false,
+});
+
+const openResyncDialog = () => {
+  const now = new Date();
+  now.setMonth(now.getMonth() - 1);
+  resyncDialog.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
+  resyncDialog.visible = true;
+};
+
+const doResync = () => {
+  if (!resyncDialog.month) {
+    Msg.message('请选择月份', 'warning');
+    return;
+  }
+  Msg.confirm(`确认重新同步 ${resyncDialog.month} 的站点统计数据吗?此操作将覆盖该月已有数据。`).then(() => {
+    resyncDialog.loading = true;
+    $body('/stat/resyncStationStatMonth?statMonth=' + resyncDialog.month, {}).then((res: any) => {
+      Msg.message(res.msg || '同步完成', 'success');
+      resyncDialog.visible = false;
+      loadData(true);
+    }).catch(() => {
+      Msg.message('同步失败', 'error');
+    }).finally(() => {
+      resyncDialog.loading = false;
+    });
+  });
+};
+
 // 初始化表格数据
 const handleCreateStatements = (statMonth) => {
   $get(`statements/create/${statMonth.id}`).then(()=>{

+ 18 - 1
admin/src/main/java/com/kym/admin/controller/StatController.java

@@ -1,5 +1,6 @@
 package com.kym.admin.controller;
 
+import com.kym.admin.jobs.StationStatJob;
 import com.kym.common.R;
 import com.kym.entity.admin.StationStatMonth;
 import com.kym.entity.admin.queryParams.StatQueryParam;
@@ -19,10 +20,12 @@ public class StatController {
 
     private final ChargeOrderService chargeOrderService;
     private final StationStatMonthService stationStatMonthService;
+    private final StationStatJob stationStatJob;
 
-    public StatController(ChargeOrderService chargeOrderService, StationStatMonthService stationStatMonthService) {
+    public StatController(ChargeOrderService chargeOrderService, StationStatMonthService stationStatMonthService, StationStatJob stationStatJob) {
         this.chargeOrderService = chargeOrderService;
         this.stationStatMonthService = stationStatMonthService;
+        this.stationStatJob = stationStatJob;
     }
 
     /**
@@ -91,4 +94,18 @@ public class StatController {
         return R.success();
     }
 
+    /**
+     * 重新同步指定月份的站点月统计数据
+     * @param statMonth 格式: "yyyy-MM"
+     */
+    @PostMapping("/resyncStationStatMonth")
+    R<?> resyncStationStatMonth(String statMonth) {
+        try {
+            int count = stationStatJob.resyncMonth(statMonth);
+            return R.success("重新同步完成,共更新 " + count + " 条记录");
+        } catch (IllegalArgumentException e) {
+            return R.failed(400, e.getMessage());
+        }
+    }
+
 }

+ 29 - 10
admin/src/main/java/com/kym/admin/jobs/StationStatJob.java

@@ -1,6 +1,5 @@
 package com.kym.admin.jobs;
 
-import cn.hutool.core.date.DateUtil;
 import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
 import com.kym.common.utils.CommUtil;
 import com.kym.entity.admin.ConnectorInfo;
@@ -19,6 +18,7 @@ import org.springframework.stereotype.Component;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.YearMonth;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.TemporalAdjusters;
 import java.util.*;
@@ -61,8 +61,8 @@ public class StationStatJob {
         var startTime = LocalDateTime.of(statDay.toLocalDate(), LocalTime.MIN);
         var endTime = LocalDateTime.of(statDay.toLocalDate(), LocalTime.MAX);
         var chargeOrderList = getChargeOrders(startTime, endTime);
-        dayService.replaceBatch((Collection<StationStatDay>) getStationStat(chargeOrderList, true));
-        dayService.saveBatch((Collection<StationStatDay>) getStationStat(chargeOrderList, true));
+        dayService.replaceBatch((Collection<StationStatDay>) getStationStat(chargeOrderList, true, LocalDate.now().minusDays(1)));
+        dayService.saveBatch((Collection<StationStatDay>) getStationStat(chargeOrderList, true, LocalDate.now().minusDays(1)));
         log.info("执行站点日统计定时任务-结束");
     }
 
@@ -77,11 +77,30 @@ public class StationStatJob {
         var startTime = statMonth.with(TemporalAdjusters.firstDayOfMonth()).with(LocalTime.MIN);
         var endTime = statMonth.with(TemporalAdjusters.lastDayOfMonth()).with(LocalTime.MAX);
         var chargeOrderList = getChargeOrders(startTime, endTime);
-        monthService.replaceBatch((Collection<StationStatMonth>) getStationStat(chargeOrderList, false));
-//        monthService.saveBatch((Collection<StationStatMonth>) getStationStat(chargeOrderList, false));
+        monthService.replaceBatch((Collection<StationStatMonth>) getStationStat(chargeOrderList, false, LocalDate.now().minusMonths(1)));
+//        monthService.saveBatch((Collection<StationStatMonth>) getStationStat(chargeOrderList, false, LocalDate.now().minusMonths(1)));
         log.info("执行站点月统计定时任务-结束");
     }
 
+    /**
+     * 手动重新同步指定月份的站点统计数据
+     * @param yearMonth 格式: "yyyy-MM"
+     * @return 生成的记录数
+     */
+    public int resyncMonth(String yearMonth) {
+        var ym = YearMonth.parse(yearMonth, DateTimeFormatter.ofPattern("yyyy-MM"));
+        if (!ym.isBefore(YearMonth.now())) {
+            throw new IllegalArgumentException("只允许同步本月之前的数据");
+        }
+        var startTime = ym.atDay(1).atTime(LocalTime.MIN);
+        var endTime = ym.atEndOfMonth().atTime(LocalTime.MAX);
+        var targetDate = ym.atDay(1);
+        var chargeOrderList = getChargeOrders(startTime, endTime);
+        var stats = (Collection<StationStatMonth>) getStationStat(chargeOrderList, false, targetDate);
+        monthService.replaceBatch(stats);
+        return stats.size();
+    }
+
     // 只执行一次
 //    @PostConstruct
     private void init() {
@@ -107,7 +126,7 @@ public class StationStatJob {
         log.info("执行站点初始化定时任务-结束");
     }
 
-    private List<ChargeOrder> getChargeOrders(LocalDateTime startTime, LocalDateTime endTime) {
+    List<ChargeOrder> getChargeOrders(LocalDateTime startTime, LocalDateTime endTime) {
         // 通过charge_app.t_charge_order表统计start_time为前一天且充电金额大于0的有效充电订单
         DynamicDataSourceContextHolder.push("db-miniapp");
         var res = chargeOrderService.lambdaQuery()
@@ -119,7 +138,7 @@ public class StationStatJob {
         return res;
     }
 
-    private Collection<?> getStationStat(List<ChargeOrder> chargeOrderList, boolean isDayStat) {
+    Collection<?> getStationStat(List<ChargeOrder> chargeOrderList, boolean isDayStat, LocalDate targetDate) {
         // 统计每个站点的connector_id数量
         var stationConnectorCountMap = connectorInfoService.list().stream().collect(Collectors.groupingBy(ConnectorInfo::getStationId, Collectors.counting()));
         // 将chargeOrderList按照stationId分组
@@ -145,7 +164,7 @@ public class StationStatJob {
                     return isDayStat
                             ? new StationStatDay()
                             .setStationId(entry.getKey())
-                            .setStatDay(DateUtil.format(LocalDateTime.now().minusDays(1), "yyyy-MM-dd"))
+                            .setStatDay(targetDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
                             .setChargeUsers(chargeUsers)
                             .setValidOrders(chargeOrders.size())
                             .setTotalMoney(totalMoney)
@@ -162,7 +181,7 @@ public class StationStatJob {
 
                             : new StationStatMonth()
                             .setStationId(entry.getKey())
-                            .setStatMonth(DateUtil.format(LocalDateTime.now().minusMonths(1), "yyyy-MM"))
+                            .setStatMonth(targetDate.format(DateTimeFormatter.ofPattern("yyyy-MM")))
                             .setChargeUsers(chargeUsers)
                             .setValidOrders(chargeOrders.size())
                             .setTotalMoney(totalMoney)
@@ -173,7 +192,7 @@ public class StationStatJob {
                             .setTotalPower(totalPower)
                             .setAvgOrderElec(avgPower)
                             .setAvgOrderMoney((int) avgOrderMoney)
-                            .setAvgConnectorElec(totalPower / (stationConnectorCountMap.get(entry.getKey()) * (LocalDate.now().minusMonths(1).lengthOfMonth())));
+                            .setAvgConnectorElec(totalPower / (stationConnectorCountMap.get(entry.getKey()) * (targetDate.lengthOfMonth())));
                 })).values();
     }
 }

+ 10 - 1
charge-front/src/pages-user/wallet-refund/wallet-refund.vue

@@ -58,7 +58,7 @@
     </view>
     <style-bottom-view>
       <view class="pl-40 pr-40 pb-30 pt-30" style="background-color: #fff">
-        <style-button type="primary" @click="submit">提交申请</style-button>
+        <style-button type="primary" :disabled="submitting" @click="submit">提交申请</style-button>
       </view>
     </style-bottom-view>
     <style-result
@@ -94,6 +94,7 @@ const reasons = ref([
   "其他原因",
 ]);
 const success = ref(false);
+const submitting = ref(false);
 onLoad(() => {
   if (getApp<any>().globalData.user) {
     user.value = getApp<any>().globalData.user;
@@ -113,8 +114,13 @@ const inputReasonText = (e: any) => {
   reasonText.value = e.detail.value;
 };
 const submit = () => {
+  if (submitting.value) {
+    return;
+  }
+  submitting.value = true;
   uni.showLoading({
     title: "提交中",
+    mask: true,
   });
   let r = "";
   if (reason.value >= 0) {
@@ -138,6 +144,9 @@ const submit = () => {
         content: err.errMsg,
         showCancel: false
       })
+    })
+    .finally(() => {
+      submitting.value = false;
     });
 };
 const close = () => {

+ 1 - 1
charge-front/src/pages/user/user.vue

@@ -124,7 +124,7 @@ const menu = ref([
     icon: "/static/images/user/3.png",
   },
   {
-    title: "联系我们",
+    title: "联系我们(工作时间7:00-24:00)",
     path: "",
     icon: "/static/images/user/4.png",
   },

+ 297 - 0
deploy.sh

@@ -0,0 +1,297 @@
+#!/bin/bash
+
+# ============================================================
+# 充电项目一键部署脚本 (2核8G 服务器)
+# 用法: ./deploy.sh [admin|miniapp|all] [--skip-frontend] [--no-stop]
+#       --no-stop      构建前不停止服务(构建期间服务继续运行,但启动新进程前需手动停旧进程)
+#       --skip-frontend 跳过前端构建(仅后端代码变更时使用)
+# 调试: bash -x ./deploy.sh
+# ============================================================
+
+APP_DIR="/data/app"
+CODE_DIR="/data/code/charge-java"
+BACKUP_DIR="/data/backup"
+LOG_DIR="/data/logs"
+HISTORY_FILE="/data/history"
+CERT_DIR="/data/pem"
+
+# 构建工具路径(对齐 Jenkins 环境)
+NPM="${NPM:-/usr/local/bin/npm}"
+MVN="${MVN:-mvn}"
+
+# 将 npm 全局 bin 加入 PATH(确保 uni 等全局包可被找到)
+NPM_PREFIX=$($NPM config get prefix 2>/dev/null || echo "/usr/local")
+export PATH="$NPM_PREFIX/bin:$PATH"
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+log()   { echo -e "[$(date '+%H:%M:%S')] $1"; }
+info()  { log "${GREEN}INFO${NC}  $1"; }
+warn()  { log "${YELLOW}WARN${NC}  $1"; }
+error() { log "${RED}ERROR${NC} $1"; }
+
+mkdir -p "$LOG_DIR" "$BACKUP_DIR"
+
+# ============================================================
+# 资源限制配置 (2核8G 服务器)
+# ============================================================
+
+# ---- 构建阶段 ----
+NODE_MAX_MEM="${NODE_MAX_MEM:-2048}"      # Node 最大 old-space MB
+MAVEN_XMX="${MAVEN_XMX:-1024}"            # Maven JVM 最大堆 MB
+MAVEN_XMS="${MAVEN_XMS:-256}"             # Maven JVM 初始堆 MB
+MAVEN_THREADS="${MAVEN_THREADS:-2}"       # Maven 编译线程数
+
+# ---- 运行时 JVM (admin 模块: 管理后台 + 定时任务 + POI Excel) ----
+ADMIN_XMX="${ADMIN_XMX:-1024}"            # 最大堆 MB
+ADMIN_XMS="${ADMIN_XMS:-256}"             # 初始堆 MB
+ADMIN_META="${ADMIN_META:-256}"           # 元空间上限 MB
+ADMIN_CODE_CACHE="${ADMIN_CODE_CACHE:-128}" # JIT 代码缓存 MB
+ADMIN_XSS="${ADMIN_XSS:-256}"             # 线程栈 KB
+
+# ---- 运行时 JVM (miniapp 模块: 小程序API) ----
+MINIAPP_XMX="${MINIAPP_XMX:-768}"
+MINIAPP_XMS="${MINIAPP_XMS:-256}"
+MINIAPP_META="${MINIAPP_META:-256}"
+MINIAPP_CODE_CACHE="${MINIAPP_CODE_CACHE:-128}"
+MINIAPP_XSS="${MINIAPP_XSS:-256}"
+
+# 低优先级包装器(避免构建抢占服务 CPU)
+low_prio() {
+    nice -n 19 ionice -c 2 -n 7 "$@"
+}
+
+# ---- 参数解析 ----
+TARGET="${1:-all}"
+SKIP_FRONTEND=false
+NO_STOP=false
+for arg in "$@"; do
+    [ "$arg" = "--skip-frontend" ] && SKIP_FRONTEND=true
+    [ "$arg" = "--no-stop" ] && NO_STOP=true
+done
+
+info "开始部署 充电项目 (目标=$TARGET, 零停机=$NO_STOP)"
+info "Maven:$MVN | NPM:$NPM | Node:${NODE_MAX_MEM}M | Admin-JVM:${ADMIN_XMX}M | Miniapp-JVM:${MINIAPP_XMX}M"
+
+# ---- 拉取代码 ----
+info "拉取最新代码..."
+cd "$CODE_DIR" || { error "无法进入 $CODE_DIR"; exit 1; }
+low_prio git pull origin master || { error "git pull 失败"; exit 1; }
+
+# ============================================================
+# 阶段 1: 停止服务 (释放内存给构建)
+# ============================================================
+stop_service() {
+    local module=$1
+    local pid=$(ps -ef | grep java | grep "$module" | grep -v grep | awk '{print $2}')
+
+    if [ -z "$pid" ]; then
+        info "$module 未在运行"
+        return 0
+    fi
+
+    for p in $pid; do
+        info "终止 $module (pid=$p)..."
+        kill "$p" 2>/dev/null
+
+        for i in $(seq 1 10); do
+            if ! kill -0 "$p" 2>/dev/null; then
+                info "进程 $p 已退出"
+                break
+            fi
+            sleep 1
+        done
+
+        if kill -0 "$p" 2>/dev/null; then
+            warn "强制终止 $p"
+            kill -9 "$p" 2>/dev/null
+            sleep 1
+        fi
+    done
+}
+
+if [ "$NO_STOP" = false ]; then
+    case "$TARGET" in
+        admin)   stop_service "admin-0.0.1-SNAPSHOT" ;;
+        miniapp) stop_service "miniapp-0.0.1-SNAPSHOT" ;;
+        all)     stop_service "admin-0.0.1-SNAPSHOT"
+                 stop_service "miniapp-0.0.1-SNAPSHOT" ;;
+    esac
+    sleep 3
+fi
+
+# ============================================================
+# 阶段 2: 安装公共模块到本地仓库 (对齐 Jenkins mvn install 流程)
+# ============================================================
+info "安装公共模块到本地仓库..."
+(
+    cd "$CODE_DIR"
+    low_prio $MVN install -DskipTests -Dmaven.test.skip=true
+) || { error "Maven install 失败"; exit 1; }
+
+# ============================================================
+# 阶段 3: 前端构建
+# ============================================================
+
+if [ "$SKIP_FRONTEND" = false ] && [ "$TARGET" != "miniapp" ]; then
+    # ---- 管理后台前端 (admin-web) ----
+    info "构建前端 admin-web ..."
+
+    # 清理旧前端产物,避免残留文件打入 jar
+    rm -rf "$CODE_DIR/admin/src/main/resources/static/"
+    mkdir -p "$CODE_DIR/admin/src/main/resources/static/"
+
+    (
+        cd "$CODE_DIR/admin-web" || exit 1
+        export NODE_OPTIONS="--max-old-space-size=$NODE_MAX_MEM"
+        $NPM install
+        low_prio $NPM run build
+    ) || { error "admin-web 构建失败"; exit 1; }
+    info "admin-web 构建完成"
+
+    # ---- 小程序前端 (charge-front, uni-app) ----
+    info "构建前端 charge-front ..."
+    (
+        # uni-app CLI 依赖,首次部署需全局安装
+        if ! command -v uni >/dev/null 2>&1; then
+            info "安装 @dcloudio/uvm ..."
+            $NPM install -g @dcloudio/uvm
+        fi
+        cd "$CODE_DIR/charge-front" || exit 1
+        export NODE_OPTIONS="--max-old-space-size=$NODE_MAX_MEM"
+        $NPM install
+        low_prio $NPM run build
+    ) || { error "charge-front 构建失败"; exit 1; }
+    info "charge-front 构建完成"
+fi
+
+# ============================================================
+# 阶段 4: Maven 打包
+# ============================================================
+export MAVEN_OPTS="-Xmx${MAVEN_XMX}m -Xms${MAVEN_XMS}m -XX:+UseG1GC"
+
+if [ "$TARGET" = "miniapp" ]; then
+    info "Maven 打包 (仅 miniapp)..."
+    (
+        cd "$CODE_DIR/miniapp"
+        low_prio $MVN clean package -DskipTests -Dmaven.test.skip=true
+    ) || { error "Maven 打包失败"; exit 1; }
+elif [ "$TARGET" = "admin" ]; then
+    info "Maven 打包 (仅 admin)..."
+    (
+        cd "$CODE_DIR/admin"
+        low_prio $MVN clean package -DskipTests -Dmaven.test.skip=true
+    ) || { error "Maven 打包失败"; exit 1; }
+else
+    info "Maven 打包 (全量, 线程=${MAVEN_THREADS})..."
+    (
+        cd "$CODE_DIR"
+        low_prio $MVN clean package -DskipTests \
+            -T "${MAVEN_THREADS}" \
+            -Dmaven.test.skip=true \
+            -Dmaven.compiler.fork=false
+    ) || { error "Maven 打包失败"; exit 1; }
+fi
+
+sleep 2
+
+# ============================================================
+# 阶段 5: 备份 & 部署 & 启动
+# ============================================================
+
+backup_and_copy() {
+    local module=$1
+    local jar_src="$CODE_DIR/$module/target/${module}-0.0.1-SNAPSHOT.jar"
+    local jar_dst="$APP_DIR/${module}-0.0.1-SNAPSHOT.jar"
+
+    if [ ! -f "$jar_src" ]; then
+        error "未找到 jar 文件: $jar_src"
+        exit 1
+    fi
+
+    if [ -f "$jar_dst" ]; then
+        cp "$jar_dst" "$BACKUP_DIR/${module}-0.0.1-SNAPSHOT.jar.$(date +%Y%m%d_%H%M%S)"
+        info "已备份 $module (保留最近 5 个)"
+        ls -t "$BACKUP_DIR/${module}"-*.jar 2>/dev/null | tail -n +6 | xargs -r rm -f
+    fi
+    cp "$jar_src" "$jar_dst"
+    info "$module jar 已复制到 $APP_DIR"
+}
+
+# 构建模块专用的 JVM 参数
+build_jvm_opts() {
+    local module=$1
+    if [ "$module" = "admin" ]; then
+        echo "-Xmx${ADMIN_XMX}m -Xms${ADMIN_XMS}m -Xss${ADMIN_XSS}k \
+-XX:MaxMetaspaceSize=${ADMIN_META}m -XX:ReservedCodeCacheSize=${ADMIN_CODE_CACHE}m \
+-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseStringDeduplication \
+-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR}/ \
+-Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom"
+    else
+        echo "-Xmx${MINIAPP_XMX}m -Xms${MINIAPP_XMS}m -Xss${MINIAPP_XSS}k \
+-XX:MaxMetaspaceSize=${MINIAPP_META}m -XX:ReservedCodeCacheSize=${MINIAPP_CODE_CACHE}m \
+-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseStringDeduplication \
+-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR}/ \
+-Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom"
+    fi
+}
+
+start_service() {
+    local module=$1
+    local jar_file="${module}-0.0.1-SNAPSHOT.jar"
+    local start_log="$LOG_DIR/${module}.log"
+    local jvm_opts=$(build_jvm_opts "$module")
+
+    info "启动 $module (JVM: $(echo $jvm_opts | tr -s ' '))..."
+
+    # admin 模块需要指定微信支付证书路径
+    local cert_arg=""
+    if [ "$module" = "admin" ]; then
+        cert_arg="-Dwechat.payment.certPath=${CERT_DIR}/apiclient_cert.pem -Dwechat.payment.keyPath=${CERT_DIR}/apiclient_key.pem"
+    fi
+
+    cd "$APP_DIR"
+    nohup java $jvm_opts -jar -Dspring.profiles.active=prod $cert_arg "$jar_file" > "$start_log" 2>&1 &
+
+    sleep 5
+    local new_pid=$(ps -ef | grep java | grep "$module" | grep -v grep | awk '{print $2}')
+
+    if [ -n "$new_pid" ]; then
+        info "$module 启动成功 (pid=$new_pid)"
+        echo "$(date) $module restarted" >> "$HISTORY_FILE"
+    else
+        error "$module 可能启动失败,查看日志:"
+        echo "--- $start_log ---"
+        tail -30 "$start_log"
+        echo "--- end ---"
+        exit 1
+    fi
+}
+
+case "$TARGET" in
+    admin)
+        backup_and_copy "admin"
+        start_service "admin"
+        ;;
+    miniapp)
+        backup_and_copy "miniapp"
+        start_service "miniapp"
+        ;;
+    all)
+        backup_and_copy "admin"
+        backup_and_copy "miniapp"
+        start_service "miniapp"
+        start_service "admin"
+        ;;
+    *)
+        error "未知模块: $TARGET (可选: admin | miniapp | all)"
+        exit 1
+        ;;
+esac
+
+echo "========================================"
+info "充电项目 部署完成"
+echo "========================================"

+ 15 - 0
entity/src/main/java/com/kym/entity/admin/queryParams/InvoiceDetailQueryParam.java

@@ -2,6 +2,9 @@ package com.kym.entity.admin.queryParams;
 
 import com.kym.entity.common.PageParams;
 import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
 
 /**
  * @author skyline
@@ -15,4 +18,16 @@ public class InvoiceDetailQueryParam extends PageParams {
      */
     private String applyId;
 
+    /**
+     * 开票时间(起)
+     */
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startTime;
+
+    /**
+     * 开票时间(止)
+     */
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime endTime;
+
 }

+ 5 - 0
entity/src/main/java/com/kym/entity/common/RedisKeys.java

@@ -45,4 +45,9 @@ public interface RedisKeys {
      * 站点白名单用户列表(站点ID -> 用户ID集合)
      */
     String STATION_WHITELIST_USERS = "STATION_WHITELIST_USERS:";
+
+    /**
+     * 用户退款申请分布式锁
+     */
+    String REFUND_LOCK = "REFUND_LOCK:";
 }

+ 5 - 2
miniapp/src/main/java/com/kym/miniapp/jobs/EquipmentChargeStatusJob.java

@@ -61,6 +61,7 @@ public class EquipmentChargeStatusJob {
         log.info("设备充电状态信息(订单信息)更新定时任务启动...");
         var orders = redisTemplate.opsForHash().values(RedisKeys.CHARGE_ORDER_EQUIP_CHARGE_STATUS);
         if (CommUtil.isNotEmptyAndNull(orders)) {
+            // 实际已结束订单
             var orderList = new ArrayList<ChargeOrder>();
             orders.forEach(order -> {
                 var chargeOrder = JSONObject.parseObject(order.toString(), ChargeOrder.class);
@@ -69,7 +70,7 @@ public class EquipmentChargeStatusJob {
                         .eq(ChargeOrder::getStartChargeSeq, chargeOrder.getStartChargeSeq())
                         .select(ChargeOrder::getChargeStatus, ChargeOrder::getOrderStatus)
                         .one();
-                if (dbOrder != null && dbOrder.getChargeStatus().equals(ChargeOrder.CHARGE_STATUS_已结束)) {
+                if (dbOrder != null && (dbOrder.getChargeStatus().equals(ChargeOrder.CHARGE_STATUS_已结束) || dbOrder.getOrderStatus().equals(ChargeOrder.ORDER_STATUS_成功))) {
                     log.info("订单{}已在DB中结束,跳过Redis数据覆盖,清理Redis缓存", chargeOrder.getStartChargeSeq());
                     redisTemplate.opsForHash().delete(RedisKeys.CHARGE_ORDER_EQUIP_CHARGE_STATUS, chargeOrder.getStartChargeSeq());
                     return;
@@ -81,7 +82,9 @@ public class EquipmentChargeStatusJob {
             });
             if (!orderList.isEmpty()) {
                 chargeOrderService.updateBatchByQueryWrapper(orderList, order ->
-                        new QueryWrapper<ChargeOrder>().eq("start_charge_seq", order.getStartChargeSeq()));
+                        new QueryWrapper<ChargeOrder>().eq("start_charge_seq", order.getStartChargeSeq())
+                                .ne("charge_status", ChargeOrder.CHARGE_STATUS_已结束)
+                                .ne("order_status", ChargeOrder.ORDER_STATUS_成功));
             }
         }
         log.info("设备充电状态信息(订单信息)更新定时任务结束,本次更新{}条订单...", orders.size());

+ 61 - 38
service/src/main/java/com/kym/service/admin/impl/InvoiceDetailServiceImpl.java

@@ -1,11 +1,10 @@
 package com.kym.service.admin.impl;
 
-import cn.hutool.core.date.DateUtil;
 import cn.hutool.core.util.NumberUtil;
 import cn.hutool.poi.excel.ExcelUtil;
 import cn.hutool.poi.excel.ExcelWriter;
-import com.alibaba.fastjson2.JSON;
 import com.baomidou.dynamic.datasource.annotation.DS;
+import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
 import com.github.pagehelper.PageHelper;
 import com.github.yulichang.base.MPJBaseServiceImpl;
 import com.kym.common.utils.CommUtil;
@@ -13,7 +12,6 @@ import com.kym.entity.admin.InvoiceDetail;
 import com.kym.entity.admin.queryParams.InvoiceDetailQueryParam;
 import com.kym.entity.common.PageBean;
 import com.kym.entity.miniapp.Invoice;
-import com.kym.entity.wechat.FapiaoApplications;
 import com.kym.mapper.admin.InvoiceDetailMapper;
 import com.kym.service.admin.InvoiceDetailService;
 import com.kym.service.miniapp.DataDictService;
@@ -42,62 +40,77 @@ public class InvoiceDetailServiceImpl extends MPJBaseServiceImpl<InvoiceDetailMa
     @Override
     public PageBean<InvoiceDetail> listInvoiceDetail(InvoiceDetailQueryParam params) {
         PageHelper.startPage(params.getPageNum(), params.getPageSize());
-        var res = lambdaQuery()
-                .eq(CommUtil.isNotEmptyAndNull(params.getApplyId()), InvoiceDetail::getApplyId, params.getApplyId())
-                .orderByDesc(InvoiceDetail::getFapiaoTime)
-                .list();
+        var res = buildQuery(params).orderByDesc(InvoiceDetail::getFapiaoTime).list();
         return new PageBean<>(res);
     }
 
     @Override
     public ExcelWriter exportInvoiceDetail(InvoiceDetailQueryParam params) {
-        var invoiceDetailList = lambdaQuery()
-                .eq(CommUtil.isNotEmptyAndNull(params.getApplyId()), InvoiceDetail::getApplyId, params.getApplyId())
-                .orderByDesc(InvoiceDetail::getFapiaoTime)
-                .list();
+        var invoiceDetailList = buildQuery(params).orderByDesc(InvoiceDetail::getFapiaoTime).list();
         ExcelWriter writer = ExcelUtil.getWriter();
 
         // excel 行信息
         List<Map<String, Object>> rows = new ArrayList<>();
 
-        // 单张发票若发票行有多行,则每项单独一行
         invoiceDetailList.forEach(invoiceDetail -> {
-            var items = JSON.parseArray(JSON.toJSONString(invoiceDetail.getItems()), FapiaoApplications.FapiaoItem.class);
-            items.forEach(item -> {
-                var row = new HashMap<String, Object>();
-                // 发票基本信息
-                row.put("blue_fapiao.fapiao_time", invoiceDetail.getFapiaoTime());
-                row.put("blue_fapiao.fapiao_code", invoiceDetail.getBlueFapiao().getFapiao_code());
-                row.put("blue_fapiao.fapiao_number", invoiceDetail.getBlueFapiao().getFapiao_number());
-                row.put("buyer_information.type", Invoice.TYPE_企业.equals(invoiceDetail.getBuyerInformation().getType()) ? "企业" : "个人");
-                row.put("buyer_information.name", invoiceDetail.getBuyerInformation().getName());
-                row.put("buyer_information.taxpayer_id", invoiceDetail.getBuyerInformation().getTaxpayer_id());
-                row.put("fapiao_information.status", "否"); // invoiceDetail.getStatus()
-
-                row.putAll(Map.of(
-                        "goods_name", item.getGoods_name(),
-//             "specification",item.getSpecification(),
-//             "unit",item.getUnit(),
-                        "quantity", item.getQuantity() / 100000000,
-                        "amount", NumberUtil.decimalFormat("#.##", item.getAmount() / 100f),
-                        "tax_amount", NumberUtil.decimalFormat("#.##", item.getTax_amount() / 100f),
-                        "tax_rate", NumberUtil.decimalFormat("#.##", item.getTax_rate() / 10000f)
-                ));
-
+            // 发票公共信息
+            var blueFapiao = invoiceDetail.getBlueFapiao();
+            var buyerInfo = invoiceDetail.getBuyerInformation();
+
+            Map<String, Object> baseInfo = new HashMap<>();
+            baseInfo.put("blue_fapiao.fapiao_time", invoiceDetail.getFapiaoTime());
+            baseInfo.put("blue_fapiao.fapiao_number", blueFapiao != null ? blueFapiao.getFapiao_number() : "");
+            baseInfo.put("buyer_information.type", buyerInfo != null && Invoice.TYPE_企业.equals(buyerInfo.getType()) ? "企业" : "个人");
+            baseInfo.put("buyer_information.name", buyerInfo != null ? buyerInfo.getName() : "");
+            baseInfo.put("buyer_information.taxpayer_id", buyerInfo != null ? buyerInfo.getTaxpayer_id() : "");
+            baseInfo.put("fapiao_information.status", "REVERSED".equals(invoiceDetail.getStatus()) ? "是" : "否");
+
+            var items = invoiceDetail.getItems();
+            if (items != null && !items.isEmpty()) {
+                // 旧版微信发票:含发票行明细,每行展开一条记录
+                items.forEach(item -> {
+                    var row = new HashMap<>(baseInfo);
+                    row.put("goods_name", item.getGoods_name());
+                    row.put("quantity", item.getQuantity() / 100000000);
+                    row.put("amount", NumberUtil.decimalFormat("#.##", item.getAmount() / 100f));
+                    row.put("tax_amount", NumberUtil.decimalFormat("#.##", item.getTax_amount() / 100f));
+                    row.put("tax_rate", NumberUtil.decimalFormat("#.##", item.getTax_rate() / 10000f));
+                    rows.add(row);
+                });
+            } else {
+                // 新版航信发票:无发票行明细,根据含税总金额反算各项(税率固定13%)
+                var row = new HashMap<>(baseInfo);
+                var totalAmount = invoiceDetail.getTotalAmount();
+                if (totalAmount != null && totalAmount > 0) {
+                    double totalYuan = totalAmount / 100.0;
+                    double amountYuan = totalYuan / 1.13;
+                    double taxYuan = totalYuan - amountYuan;
+                    row.put("goods_name", "充电费用");
+                    row.put("quantity", 1);
+                    row.put("amount", NumberUtil.decimalFormat("#.##", amountYuan));
+                    row.put("tax_amount", NumberUtil.decimalFormat("#.##", taxYuan));
+                    row.put("tax_rate", "13%");
+                    row.put("total_amount_has_tax", NumberUtil.decimalFormat("#.##", totalYuan));
+                } else {
+                    row.put("goods_name", "");
+                    row.put("quantity", "");
+                    row.put("amount", "");
+                    row.put("tax_amount", "");
+                    row.put("tax_rate", "");
+                    row.put("total_amount_has_tax", "");
+                }
                 rows.add(row);
-            });
+            }
         });
 
-        writer.addHeaderAlias("blue_fapiao.fapiao_code", "发票代码");
         writer.addHeaderAlias("blue_fapiao.fapiao_number", "发票号码");
         writer.addHeaderAlias("blue_fapiao.fapiao_time", "开票日期");
         writer.addHeaderAlias("goods_name", "货物、应税劳务及服务");
-        writer.addHeaderAlias("specification", "规格型号");
         writer.addHeaderAlias("quantity", "数量");
-        writer.addHeaderAlias("unit", "单位");
         writer.addHeaderAlias("amount", "金额");
         writer.addHeaderAlias("tax_rate", "税率");
         writer.addHeaderAlias("tax_amount", "税额");
+        writer.addHeaderAlias("total_amount_has_tax", "价税合计");
         writer.addHeaderAlias("fapiao_information.status", "是否作废");
         writer.addHeaderAlias("buyer_information.type", "购买方类型");
         writer.addHeaderAlias("buyer_information.name", "购方单位名称");
@@ -115,4 +128,14 @@ public class InvoiceDetailServiceImpl extends MPJBaseServiceImpl<InvoiceDetailMa
         removeByMap(Map.of("apply_id", invoiceDetail.getApplyId()));
         save(invoiceDetail);
     }
+
+    /**
+     * 构建带公共过滤条件的查询
+     */
+    private LambdaQueryChainWrapper<InvoiceDetail> buildQuery(InvoiceDetailQueryParam params) {
+        return lambdaQuery()
+                .eq(CommUtil.isNotEmptyAndNull(params.getApplyId()), InvoiceDetail::getApplyId, params.getApplyId())
+                .ge(params.getStartTime() != null, InvoiceDetail::getFapiaoTime, params.getStartTime())
+                .le(params.getEndTime() != null, InvoiceDetail::getFapiaoTime, params.getEndTime());
+    }
 }

+ 11 - 2
service/src/main/java/com/kym/service/mybatisplus/MyBaseServiceImpl.java

@@ -71,8 +71,17 @@ public class MyBaseServiceImpl<M extends MyBaseMapper<T>, T> extends ServiceImpl
     }
 
     protected String getSqlStatement(MySqlMethod sqlMethod) {
-        // MyBatis-Plus 3.5.9+ mapperClass 字段变为 private,使用 baseMapper.getClass() 替代
-        return getSqlStatement(this.baseMapper.getClass(), sqlMethod);
+        return getSqlStatement(resolveMapperClass(), sqlMethod);
+    }
+
+    private Class<?> resolveMapperClass() {
+        Class<?> mapperClass = this.baseMapper.getClass();
+        for (Class<?> iface : mapperClass.getInterfaces()) {
+            if (MyBaseMapper.class.isAssignableFrom(iface) && iface != MyBaseMapper.class) {
+                return iface;
+            }
+        }
+        return mapperClass;
     }
 
     public static String getSqlStatement(Class<?> mapper, MySqlMethod sqlMethod) {

+ 13 - 0
service/src/main/java/com/kym/service/platform/impl/PlatformNotifyServiceImpl.java

@@ -278,6 +278,19 @@ public class PlatformNotifyServiceImpl implements PlatformNotifyService {
         LOGGER.info("【{}互联互通平台推送】收到停止充电结果推送:{},解密数据:{}", platformName, json, data);
         var startChargeSeq = data.getString("StartChargeSeq");
         var chargeOrder = chargeOrderService.getChargingOrderByStartChargeSeq(startChargeSeq);
+        // 订单已结算或已到终态,跳过处理,防止覆盖已结算数据
+        if (chargeOrder == null || chargeOrder.getOrderStatus() != ChargeOrder.ORDER_STATUS_未知) {
+            LOGGER.info("【{}互联互通平台推送】停止充电结果:订单{}已到终态,跳过处理", platformName, startChargeSeq);
+            var params = """
+                    {
+                        "StartChargeSeq":"%s",
+                        "SuccStat":%d,
+                        "FailReason":%d
+                    }
+                    """.formatted(startChargeSeq, 0, 0);
+            var platform = PlatformCache.INSTANCE.getPlatformByName(platformName);
+            return PlatformAesUtil.encrypt(platform.getUserDataSecret(), platform.getUserDataSecretIv(), params);
+        }
         if (data.containsKey("SuccStat") && data.getIntValue("SuccStat") == 0) {
             chargeOrder.setChargeStatus(data.getIntValue("StartChargeSeqStat"));
             chargeOrderService.updateById(chargeOrder);

+ 39 - 12
service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -14,6 +14,7 @@ import com.kym.common.exception.BusinessException;
 import com.kym.common.utils.CommUtil;
 import com.kym.common.utils.LambadaTools;
 import com.kym.common.utils.OrderUtils;
+import com.kym.entity.common.RedisKeys;
 import com.kym.entity.miniapp.Account;
 import com.kym.entity.miniapp.*;
 import com.kym.service.admin.ActivityService;
@@ -46,6 +47,7 @@ import lombok.SneakyThrows;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.core.io.ClassPathResource;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
@@ -62,6 +64,7 @@ import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 
@@ -101,11 +104,14 @@ public class WxPayServiceImpl implements WxPayService {
 
     private final UserRechargeRightsService userRechargeRightsService;
 
+    private final StringRedisTemplate stringRedisTemplate;
+
 
     public WxPayServiceImpl(WxPayConfig conf, WalletDetailService walletDetailService,
                             PayLogService payLogService, AccountService accountService, ChargeOrderService chargeOrderService,
                             RefundLogService refundLogService,
-                            PlatformApiService platformApiService, ActivityService activityService, UserRechargeRightsService userRechargeRightsService) {
+                            PlatformApiService platformApiService, ActivityService activityService, UserRechargeRightsService userRechargeRightsService,
+                            StringRedisTemplate stringRedisTemplate) {
         this.conf = conf;
         this.walletDetailService = walletDetailService;
         this.payLogService = payLogService;
@@ -115,6 +121,7 @@ public class WxPayServiceImpl implements WxPayService {
         this.platformApiService = platformApiService;
         this.activityService = activityService;
         this.userRechargeRightsService = userRechargeRightsService;
+        this.stringRedisTemplate = stringRedisTemplate;
     }
 
     /**
@@ -367,19 +374,36 @@ public class WxPayServiceImpl implements WxPayService {
     @Transactional(rollbackFor = Exception.class)
     public void applyWxRefund(String reason) {
         var userId = StpUtil.getLoginIdAsLong();
-        var chargeOrder = chargeOrderService.getChargingOrderByUserId(userId);
-        if (chargeOrder != null) {
-            throw new BusinessException("存在未完结的订单,请等待所有订单完结之后重试");
-        }
 
-        var account = accountService.getAccountByUserId(userId);
-        if (account.getBalance() <= 0) {
-            throw new BusinessException("账户余额不足,无需退款");
-        }
-        // 校验余额大于优惠金额
-        if (account.getBalance() <= account.getDiscountAmount()) {
-            throw new BusinessException("不可退金额高于钱包余额,不支持退款");
+        // 防重复提交:Redis分布式锁,同一用户10秒内只能提交一次退款申请
+        String lockKey = RedisKeys.REFUND_LOCK + userId;
+        Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
+        if (Boolean.FALSE.equals(locked)) {
+            throw new BusinessException("退款申请正在处理中,请勿重复提交");
         }
+        try {
+            // 防重复提交:检查是否已有处理中的退款申请
+            var existingRefund = refundLogService.lambdaQuery()
+                    .eq(RefundLog::getUserId, userId)
+                    .in(RefundLog::getStatus, RefundLog.STATUS_退款已申请, RefundLog.STATUS_退款处理中)
+                    .one();
+            if (existingRefund != null) {
+                throw new BusinessException("您已有退款申请正在处理中,请勿重复提交");
+            }
+
+            var chargeOrder = chargeOrderService.getChargingOrderByUserId(userId);
+            if (chargeOrder != null) {
+                throw new BusinessException("存在未完结的订单,请等待所有订单完结之后重试");
+            }
+
+            var account = accountService.getAccountByUserId(userId);
+            if (account.getBalance() <= 0) {
+                throw new BusinessException("账户余额不足,无需退款");
+            }
+            // 校验余额大于优惠金额
+            if (account.getBalance() <= account.getDiscountAmount()) {
+                throw new BusinessException("不可退金额高于钱包余额,不支持退款");
+            }
 
         // 将余额转移至冻结余额
         accountService.lambdaUpdate().setSql(" frozen_amount = (frozen_amount + balance) ,balance = 0").eq(Account::getUserId, userId).update();
@@ -467,6 +491,9 @@ public class WxPayServiceImpl implements WxPayService {
             LOGGER.error("退款异常:userId:{},退款金额:{},payLogs:{}", userId, originalRefundAmount, payLogs);
             throw new BusinessException("退款异常");
         }
+        } finally {
+            stringRedisTemplate.delete(lockKey);
+        }
     }