6 Commits e9c3d3ebf7 ... cd1b87602f

Author SHA1 Message Date
  skyline cd1b87602f fix: 支付回调幂等加固——未入账不再应答200(改为500等待微信重试)、回调锁改为事务提交后释放、t_pay_log唯一索引兜底重复入账 1 week ago
  skyline 71a4e3b77f feat: 常见问题页电话客服图标重绘为扁平化SVG(品牌青绿圆徽+白色客服耳机,矢量无损),移除旧位图 2 weeks ago
  skyline 39b9323331 feat: 我的卡包页重设计——票券式卡片(面额区+打孔分隔+信息区)替代背景图贴片,钱包规范tab等宽居中带数量徽标(优惠券在左默认选中),空状态改图标+文案,失效卡整体置灰,导航栏底色统一#F4F7F6 2 weeks ago
  skyline a048a8078c feat: 预约充电确认页重设计——倒计时升级为时/分/秒数字块主卡(含预计开始时间与等待开始标签),取消预约收入卡片底部,费用行与余额卡token化加按压反馈,页面底色统一#F4F7F6并加卡片入场动效 2 weeks ago
  skyline a08e7fbe1a fix: 修正logback ERROR过滤器类名(LevelFilter位于classic包),修复应用启动失败 3 weeks ago
  skyline 13d4e975f0 feat: 日志体系优化——支付回调按异常类型区分应答并显式打印订单关键字段,审计切面失败请求也落库,logback增加保留上限/全日期/ERROR单独文件/异步写盘,MDC新增uid便于按用户串联日志 3 weeks ago

+ 4 - 0
admin/src/main/java/com/kym/admin/config/SaTokenConfigure.java

@@ -2,6 +2,7 @@ package com.kym.admin.config;
 
 import cn.dev33.satoken.interceptor.SaInterceptor;
 import cn.dev33.satoken.stp.StpUtil;
+import com.kym.common.interceptor.UserIdMdcInterceptor;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -33,6 +34,9 @@ public class SaTokenConfigure implements WebMvcConfigurer {
                         "/**/*.ico",
                         "/"
                 );
+        // 将登录用户ID写入MDC,便于按用户串联日志
+        registry.addInterceptor(new UserIdMdcInterceptor())
+                .addPathPatterns("/**");
         // TODO: 2023-07-11 具体功能权限校验后续开发
     }
 

+ 41 - 6
admin/src/main/resources/logback-spring.xml

@@ -2,22 +2,56 @@
 <configuration scan="true" scanPeriod="60 seconds" debug="false">
     <contextName>logback</contextName>
     <property name="log.path" value="logs/admin/admin.log"/>
-    <property name="CONSOLE_LOG_PATTERN"
-              value="%date{yyyy-MM-dd HH:mm:ss} | %highlight(%-5level) | %boldYellow(%thread) | %boldGreen(%logger) | %msg%n"/>
+    <property name="error.path" value="logs/admin/admin-error.log"/>
+    <property name="FILE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%X{uid}|%msg%n"/>
     <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
         <encoder>
-            <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%msg%n</pattern>
+            <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%X{uid}|%msg%n</pattern>
         </encoder>
     </appender>
     <appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <file>${log.path}</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
             <fileNamePattern>./logs/admin/admin-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>10GB</totalSizeCap>
         </rollingPolicy>
         <encoder>
-            <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%msg%n</pattern>
+            <pattern>${FILE_PATTERN}</pattern>
         </encoder>
     </appender>
+    <!-- ERROR单独落盘,便于告警对接与问题定位 -->
+    <appender name="errorFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>ERROR</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+        <file>${error.path}</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>./logs/admin/admin-error-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>2GB</totalSizeCap>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${FILE_PATTERN}</pattern>
+        </encoder>
+    </appender>
+    <!-- 异步写盘;includeCallerData必须开启,否则%F:%L打印为空 -->
+    <appender name="asyncFile" class="ch.qos.logback.classic.AsyncAppender">
+        <queueSize>1024</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+        <neverBlock>true</neverBlock>
+        <includeCallerData>true</includeCallerData>
+        <appender-ref ref="file"/>
+    </appender>
+    <appender name="asyncErrorFile" class="ch.qos.logback.classic.AsyncAppender">
+        <queueSize>1024</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+        <neverBlock>true</neverBlock>
+        <includeCallerData>true</includeCallerData>
+        <appender-ref ref="errorFile"/>
+    </appender>
     <!-- <logger name="org.springframework.web" level="DEBUG"/> -->
     <logger name="org.springframework" level="info"/>
     <logger name="org.hibernate" level="info"/>
@@ -33,6 +67,7 @@
     <logger name="io.lettuce" level="warn"/>
     <root level="debug">
         <appender-ref ref="console"/>
-        <appender-ref ref="file"/>
+        <appender-ref ref="asyncFile"/>
+        <appender-ref ref="asyncErrorFile"/>
     </root>
-</configuration>
+</configuration>

+ 184 - 63
charge-front/src/pages-charge/appointment/appointment.vue

@@ -1,7 +1,7 @@
 <template>
   <view :class="['page']">
     <view v-if="station && priceInfo">
-      <view class="block">
+      <view class="block anim-fade-up">
         <view class="station">
           <ChargeMachine
               :title="'NO.' + equipment.shortId"
@@ -21,49 +21,49 @@
       </view>
       <!--      <view class="mt-40 ml-30 color-999 fs-32 fw-500">费用说明</view>-->
       <view
-          class="mt-20 block height-96 flex-align-center pl-30 pr-30"
+          class="mt-20 block height-96 flex-align-center pl-30 pr-30 pressable anim-fade-up"
+          style="animation-delay: 0.04s"
+          hover-class="pressed"
           @click="openPriceDesc"
       >
-        <view class="fs-28 color-000">充电费用</view>
-        <view class="ml-64 fs-26 color-333"
+        <view class="fs-28 color-333 fw-500">充电费用</view>
+        <view class="ml-auto fs-26 color-666 mr-8"
         >{{ priceInfo.minPrice }}~{{ priceInfo.maxPrice }}元/度
         </view
         >
-        <view class="ml-auto">
-          <uni-icons type="right" size="16" color="rgba(28,43,41,0.4)"></uni-icons>
-        </view>
+        <uni-icons type="right" size="16" color="rgba(28,43,41,0.4)"></uni-icons>
       </view>
       <template v-if="appointmentData">
-        <view class="mt-40 color-999 fs-32 fw-500">已预约充电</view>
-        <view class="mt-20 block flex-align-center" style="height: 180rpx">
-          <image
-              src="../static/icon-alarm.png"
-              mode="widthFix"
-              style="width: 32rpx; display: block"
-          ></image>
-          <view class="ml-24">
-            <view class="flex" v-if="appointmentCountDown">
-              <view class="fs-28 color-000">将在</view>
-              <view class="fs-44 lh-36 color-primary ml-8 mr-8">{{
-                  appointmentCountDown
-                }}
-              </view>
-              <view class="fs-28 color-000">后开始充电</view>
+        <view class="mt-20 block hero anim-fade-up" style="animation-delay: 0.08s">
+          <view class="flex-align-center pl-30 pr-30 pt-30">
+            <view class="hero-icon flex-center anim-breathe">
+              <uni-icons type="notification-filled" size="22" color="#2d9e95"></uni-icons>
             </view>
-            <view class="fs-24 color-999 mt-8"
-            >注意:启动充电前请勿拔插充电枪!
-            </view
-            >
+            <view class="ml-16 fs-30 fw-600 color-333">已预约充电</view>
+            <view class="hero-tag ml-auto">等待开始</view>
+          </view>
+          <view class="mt-28 fs-24 color-999 text-center">距开始充电</view>
+          <view class="mt-16 flex-center">
+            <view v-for="(part, i) in countdownParts" :key="i" class="hero-cell">
+              <view class="hero-num">{{ part }}</view>
+              <view class="hero-unit">{{ ["时", "分", "秒"][i] }}</view>
+            </view>
+          </view>
+          <view class="mt-20 fs-24 color-999 text-center" v-if="appointmentStartText"
+          >预计 {{ appointmentStartText }} 开始充电
+          </view>
+          <view class="hero-tip flex-align-center ml-30 mr-30 mt-24">
+            <uni-icons type="info" size="14" color="#2d9e95"></uni-icons>
+            <view class="ml-8 fs-24 color-666">启动充电前请勿拔插充电枪</view>
+          </view>
+          <view class="mt-30 hero-divider"></view>
+          <view class="flex-center pt-20 pb-24">
+            <view
+                class="hero-cancel pressable flex-center"
+                hover-class="pressed"
+                @click="cancelAppointment"
+            >取消预约</view>
           </view>
-        </view>
-        <view class="mt-40 flex-center">
-          <view
-              class="flex-center height-68 br-68 fs-26 color-666"
-              style="width: 184rpx; border: 1px solid #c9d4d2"
-              @click="cancelAppointment"
-          >取消预约
-          </view
-          >
         </view>
       </template>
       <template v-else>
@@ -175,39 +175,40 @@
       </template>
       <!-- 当前余额 + 低余额提醒,点击去充值(充值后返回本页) -->
       <view
-          class="mt-20 block flex-align-center pl-30 pr-30 pt-30 pb-30"
-          :style="isLowBalance ? 'background-color: #fff5f5; border: 1rpx solid rgba(244, 54, 54, 0.25);' : ''"
+          class="mt-20 block flex-align-center pl-30 pr-30 pt-30 pb-30 pressable anim-fade-up"
+          :class="{ 'balance-low': isLowBalance }"
+          style="animation-delay: 0.12s"
+          hover-class="pressed"
           @click="to('/pages-user/wallet-recharge/wallet-recharge?back=1')"
       >
-        <view class="flex-column">
-          <view class="flex-align-center">
-            <uni-icons type="wallet-filled" size="16" :color="isLowBalance ? '#f43636' : '#999'"></uni-icons>
-            <view class="fs-26 ml-8 color-333">当前余额</view>
-          </view>
-          <view class="mt-8">
-            <view class="fs-40 fw-600" :style="{ color: isLowBalance ? '#f43636' : '#333' }">{{ balanceText }}</view>
+        <view class="balance-icon flex-center flex-shrink">
+          <uni-icons
+              type="wallet-filled"
+              size="18"
+              :color="isLowBalance ? '#f43636' : '#2d9e95'"
+          ></uni-icons>
+        </view>
+        <view class="ml-20">
+          <view class="fs-24 color-999">当前余额</view>
+          <view class="flex-align-baseline mt-6">
+            <text class="fs-24 fw-500 color-666">¥</text>
+            <text
+                class="fs-44 fw-600 ml-4 lh-44"
+                :style="{ color: isLowBalance ? 'var(--color-error)' : 'var(--text-1)' }"
+            >{{ balanceValue }}
+            </text>
           </view>
           <view
               v-if="isLowBalance"
-              class="fs-24 mt-8 lh-30"
-              style="color: #f43636"
-          >余额仅 {{ balanceText }},可能无法完成本次充电
-          </view
-          >
-        </view>
-        <view class="ml-auto">
-          <view
-              class="flex-center fs-24 fw-500"
-              :style="isLowBalance
-                  ? 'height: 56rpx; border-radius: 56rpx; background-color: var(--color-error); color: #fff; padding: 0 28rpx;'
-                  : 'height: 56rpx; border-radius: 56rpx; border: 1rpx solid #c9d4d2; color: #666; padding: 0 28rpx;'"
-          >去充值
-          </view
-          >
+              class="fs-22 mt-4 lh-30"
+              style="color: var(--color-error)"
+          >余额较低,可能无法完成本次充电
+          </view>
         </view>
+        <view class="ml-auto recharge-pill flex-center fs-24 fw-500 flex-shrink">去充值</view>
       </view>
       <view class="pt-60 pb-80"></view>
-      <style-bottom-view>
+      <style-bottom-view :shadow="true">
         <view class="pt-20 pl-40 pr-40 pb-20 bg-fff flex">
           <template v-if="appointmentData">
             <view class="mr-10" style="width: 50%">
@@ -375,7 +376,7 @@ const LOW_BALANCE_THRESHOLD = 30;
 const isLowBalance = computed(
     () => Number(balance.value) > 0 && Number(balance.value) < LOW_BALANCE_THRESHOLD
 );
-const balanceText = computed(() => `${Number(balance.value).toFixed(2)}元`);
+const balanceValue = computed(() => Number(balance.value).toFixed(2));
 
 
 const refreshUserBalance = () => {
@@ -691,6 +692,25 @@ const formatAppointmentCountDown = (time: string) => {
   }:${secondTime < 10 ? "0" + secondTime : secondTime}`;
 };
 
+/** 倒计时分段:["01", "20", "52"] 对应 时/分/秒 */
+const countdownParts = computed(() => {
+  const cd = appointmentCountDown.value;
+  return cd ? String(cd).split(":") : ["--", "--", "--"];
+});
+
+/** 预约开始时间的展示文案:今天/明天 M月d日 HH:mm */
+const appointmentStartText = computed(() => {
+  if (!appointmentData.value?.startTime) {
+    return "";
+  }
+  const ts = parseDate(appointmentData.value.startTime).getTime();
+  const dayDiff = Math.round(
+      (new Date(ts).setHours(0, 0, 0, 0) - new Date().setHours(0, 0, 0, 0)) / DAY
+  );
+  const dayText = dayDiff <= 0 ? "今天" : dayDiff === 1 ? "明天" : format("M月d日", ts);
+  return `${dayText} ${format("h:m", ts)}`;
+});
+
 const submit = () => {
   if (
       chargeType.value === 0 &&
@@ -946,8 +966,8 @@ onUnload(() => {
 .page {
   min-height: 100vh;
   width: 100%;
-  padding: 20rpx;
-  background-color: #f6f7fa;
+  padding: 24rpx 30rpx 0;
+  background-color: var(--bg-page);
 }
 
 .block {
@@ -960,6 +980,107 @@ onUnload(() => {
   border-bottom: 1rpx solid var(--divider);
 }
 
+/* ============ 预约状态主卡 ============ */
+.hero-icon {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: var(--color-primary-tint-strong);
+  flex-shrink: 0;
+}
+
+.hero-tag {
+  display: flex;
+  align-items: center;
+  height: 44rpx;
+  padding: 0 20rpx;
+  border-radius: 12rpx;
+  background: var(--color-primary-tint);
+  color: var(--color-primary);
+  font-size: 24rpx;
+  font-weight: 500;
+  flex-shrink: 0;
+}
+
+.hero-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 128rpx;
+  padding: 20rpx 0 14rpx;
+  border-radius: 12rpx;
+  background: var(--color-primary-tint);
+}
+
+.hero-cell + .hero-cell {
+  margin-left: 16rpx;
+}
+
+.hero-num {
+  font-size: 52rpx;
+  line-height: 56rpx;
+  font-weight: 600;
+  color: var(--color-primary);
+  font-variant-numeric: tabular-nums;
+}
+
+.hero-unit {
+  margin-top: 4rpx;
+  font-size: 20rpx;
+  color: var(--text-3);
+}
+
+.hero-tip {
+  height: 64rpx;
+  border-radius: 12rpx;
+  background: var(--color-sec);
+}
+
+.hero-divider {
+  border-top: 1rpx solid var(--divider);
+}
+
+.hero-cancel {
+  min-width: 240rpx;
+  height: 64rpx;
+  padding: 0 48rpx;
+  border: 1rpx solid var(--divider);
+  border-radius: 32rpx;
+  background: #fff;
+  font-size: 26rpx;
+  color: var(--text-2);
+}
+
+/* ============ 余额卡 ============ */
+.balance-icon {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: var(--color-primary-tint-strong);
+}
+
+.balance-low {
+  background-color: rgba(244, 54, 54, 0.04);
+  border: 1rpx solid rgba(244, 54, 54, 0.25);
+}
+
+.balance-low .balance-icon {
+  background: rgba(244, 54, 54, 0.08);
+}
+
+.recharge-pill {
+  height: 56rpx;
+  padding: 0 28rpx;
+  border-radius: 28rpx;
+  background: var(--color-primary-tint);
+  color: var(--color-primary);
+}
+
+.balance-low .recharge-pill {
+  background: var(--color-error);
+  color: #fff;
+}
+
 /* ============ 时间选择器 ============ */
 .time-picker {
   background-color: #fff;

+ 287 - 212
charge-front/src/pages-charge/card/card.vue

@@ -1,173 +1,93 @@
 <template>
-  <view class="page pt-20 pr-40 pl-40">
-    <view class="tab-list flex-align-end">
-      <view class="fs-28 color-666 mt-20 w50" :class="mode===0?'active':''" @click="changeTab(0)">权益卡</view>
-      <view class="fs-28 color-666 mt-20 w50" :class="mode===1?'active':''" @click="changeTab(1)">优惠券</view>
-    </view>
-    <view v-if="mode===0">
+  <view class="page">
+    <!-- tabs:等宽居中 + 下划线过渡(同钱包页规范) -->
+    <view class="tabs">
       <view
-          class="item mt-20 "
-          v-for="(item, index) in list"
-          :class="item.status==0?'inactive':''"
-          :key="index"
+          v-for="tab in tabs"
+          :key="tab.value"
+          class="tab-item flex-center"
+          @click="changeTab(tab.value)"
       >
-        <image class="full-percent" src="../static/discount-bg.png"></image>
-        <view class="absolute-full flex pt-8 pb-8">
-          <view class="left  flex-justify-center pl-48">
-
-            <view class="item-content_left">
-              <view class="flex-align-end">
-                <view class="item-header__left">
-                  <image class="width-84" src="../static/discount-rights-card.png" mode="widthFix"></image>
-                  <view class="fs-48 lh-58 color-000 fw-500 ml-16">
-                    {{ (item.discount / 10).toFixed(1) }}折
-                  </view>
-                  <view class="fs-26 color-999 ml-16" v-if="mode === 0"
-                  >抵扣充电服务费
-                  </view>
-                </view>
-
-                <!--                <view class="item-header__right flex-grow">
-                                  <view class="item-status ml-10">{{ item.status == 0 ? '已失效' : '生效中' }}</view>
-                                </view>-->
-              </view>
-
-
-              <block>
-                <view class="fs-26 color-999 mt-8"
-                      @click.stop="to(`/pages-common/activity/activity?id=${item.activityId}`)">
-                  <view class="fs-26 color-999">到期时间:{{ item.endTime }}</view>
-                  <view class="flex-align-center" v-if="mode === 0">
-                    <view class="fs-26 color-999">权益余额:{{ fenToYuan(item.rightsBalance) }}元
-                      <text class="color-primary">详细规则</text>
-                      <text class="fs-24 ml-4 color-primary">>></text>
-                    </view>
-                  </view>
-                </view>
-              </block>
-            </view>
-            <view class="item-content_right">
-              <view class="item-status ml-10">{{ item.status == 0 ? '已失效' : '生效中' }}</view>
-            </view>
-
-          </view>
-          <!--          <view v-if="mode === 0" class="right width-80 flex-center">
-                      <style-checkbox
-                          :size="48"
-                          :checked="checkIndex === index"
-                          :iconsize="17"
-                      ></style-checkbox>
-                    </view>-->
-          <!--          <view
-                        v-if="mode === 1"
-                        class="right flex-center"
-                        :style="{
-                      width: '210rpx',
-                      borderLeft: '1px dashed rgba(28, 43, 41, 0.08)',
-                    }"
-                    >
-                      <view class="btn flex-center fs-28" @click.stop="toCharge(index)"
-                      >前往充值</view
-                      >
-                    </view>-->
-        </view>
-      </view>
-
-      <view style="width: 100%;text-align: center;">
-        <image v-if="!list||list.length==0" style="width: 240rpx;" mode="widthFix" src="/static/images/search-empty.png"></image>
+        <view
+            class="fs-30"
+            :style="{
+            color: mode === tab.value ? 'var(--text-1)' : 'var(--text-2)',
+            fontWeight: mode === tab.value ? '600' : '400',
+          }"
+        >{{ tab.label }}</view>
+        <view
+            v-if="tab.count"
+            class="tab-badge"
+            :class="{ 'tab-badge_off': mode !== tab.value }"
+        >{{ tab.count }}</view>
+        <view class="tab-underline" :style="{ opacity: mode === tab.value ? '1' : '0' }"></view>
       </view>
     </view>
 
-
-    <view v-else-if="mode===1">
+    <!-- :key 使 tab 切换时重新播放入场动效 -->
+    <view :key="mode">
       <view
-          class="item mt-20"
-          :class="item.usageStatus==1||item.status==0?'inactive':''"
-          v-for="(item, index) in couponList"
+          v-for="(item, index) in currentList"
           :key="index"
+          class="ticket mt-20 anim-fade-up"
+          :class="{ 'ticket-inactive': item.disabled }"
+          :style="{ animationDelay: Math.min(index, 5) * 0.04 + 's' }"
       >
-        <!--        <image
-                    class="full-percent"
-                    src="/pages-charge/static/discount-bg.png"
-                ></image>-->
-        <view class="absolute-full flex pt-8 pb-8">
-          <view class="left  flex-justify-center pl-48">
-            <view class="item-content_left">
-              <view class="flex-align-end">
-                <view class="item-header__left">
-
-                  <image
-                      class="width-84"
-                      src="../static/discount-coupon.png"
-                      mode="widthFix"
-                  ></image>
-                  <view></view>
-                  <view class="fs-48 lh-58 color-000 fw-500 ml-16" v-if="item.couponType=='FullDiscount'">
-                    {{ fenToYuan(item.discount) }}元
-                  </view>
-
-                  <view class="fs-48 lh-58 color-000 fw-500 ml-16" v-else>
-                    {{ (item.discount / 10).toFixed(1) }}折
-                  </view>
-
-                  <view class="fs-26 color-999 ml-16">
-                    充电服务费:{{ item.couponType == 'FullDiscount' ? '满减券' : '折扣券' }}
-                  </view>
-                </view>
-
-
-                <!--
-                                <view class="item-header__right flex-grow">
-                                  <view class="item-status ml-10" v-if="item.status==0">已失效</view>
-                                  <view class="item-status ml-10" v-else>{{ item.usageStatus == 0 ? '未使用' : '已使用' }}</view>
-                                </view>
-                -->
-
-
-              </view>
-
-              <block v-if="mode === 1">
-                <view class="fs-26 color-999 mt-8"
-                      @click.stop="to(`/pages-common/activity/activity?id=${item.activityId}`)">
-                  <view class="fs-26 color-999">到期时间:{{ item.endTime }}</view>
-                  <view class="flex-align-center">
-                    <view class="fs-26 color-999">服务费门槛:{{ fenToYuan(item.minServiceMoney) }}元
-                      <text class="color-primary">详细规则</text>
-                      <text class="fs-24 ml-4 color-primary">>></text>
-                    </view>
-                  </view>
-                </view>
-              </block>
-            </view>
-            <view class="item-content_right">
-              <view class="item-status ml-10" v-if="item.status==0">已失效</view>
-              <view class="item-status ml-10" v-else>{{ item.usageStatus == 0 ? '未使用' : '已使用' }}</view>
-            </view>
-
-
+        <!-- 票券左区:面额 -->
+        <view class="ticket-left">
+          <view class="flex-align-baseline">
+            <text class="ticket-num">{{ item.value }}</text>
+            <text class="ticket-unit ml-2">{{ item.unit }}</text>
+          </view>
+          <view class="ticket-type">{{ item.typeLabel }}</view>
+        </view>
+        <!-- 票券打孔分隔 -->
+        <view class="ticket-divider">
+          <view class="ticket-notch ticket-notch-top"></view>
+          <view class="ticket-notch ticket-notch-bottom"></view>
+        </view>
+        <!-- 票券右区:说明 -->
+        <view class="ticket-right">
+          <view class="flex-align-center">
+            <view class="fs-28 fw-600 color-333 ticket-title">{{ item.title }}</view>
+            <view class="ticket-status ml-auto flex-shrink">{{ item.statusText }}</view>
+          </view>
+          <view class="fs-24 color-999 mt-12">有效期至 {{ item.endTimeText }}</view>
+          <view class="fs-24 color-999 mt-8">{{ item.extra }}</view>
+          <view
+              class="ticket-link pressable"
+              hover-class="pressed"
+              hover-stay-time="80"
+              @click.stop="toActivity(item)"
+          >
+            详细规则
+            <uni-icons class="ml-4" type="right" size="12" color="#2d9e95"></uni-icons>
           </view>
         </view>
       </view>
 
-      <view style="width: 100%;text-align: center;">
-        <image v-if="!couponList||couponList.length==0" style="width: 240rpx;" mode="widthFix" src="/static/images/search-empty.png"></image>
+      <!-- 空状态 -->
+      <view v-if="!currentList.length" class="empty flex-column flex-center">
+        <view class="empty-icon flex-center">
+          <uni-icons type="wallet" size="30" color="#2d9e95"></uni-icons>
+        </view>
+        <view class="fs-26 color-999 mt-24">{{ mode === 0 ? "暂无权益卡" : "暂无优惠券" }}</view>
       </view>
     </view>
-
   </view>
 </template>
 
 <script setup lang="ts">
 import {onPullDownRefresh, onShow} from "@dcloudio/uni-app";
-import {ref} from "vue";
+import {computed, ref} from "vue";
 import {to} from "../../utils/navigate";
 import {fetchRightsAndCoupons} from "@/api/user";
-import { fenToYuan } from "@/utils/money";
+import {fenToYuan} from "@/utils/money";
+import {format, parseDate} from "@/utils/date";
 
-const mode = ref(0);
+const mode = ref(1);
 const list = ref<any[]>([]);
 const couponList = ref<any[]>([]);
-const checkIndex = ref(-1);
 
 onPullDownRefresh(() => {
   loadData();
@@ -192,105 +112,260 @@ const loadData = () => {
 
 onShow(() => {
   loadData();
+});
 
+/** 面额大数字去掉多余的 0:"20.00" -> "20"、"9.0" -> "9" */
+const stripZeros = (s: string) => s.replace(/\.?0+$/, "");
+
+const endTimeText = (item: any) =>
+    item.endTime ? format("y.M.d", parseDate(item.endTime).getTime()) : "--";
+
+interface TicketVM {
+  value: string;
+  unit: string;
+  typeLabel: string;
+  title: string;
+  statusText: string;
+  disabled: boolean;
+  endTimeText: string;
+  extra: string;
+  activityId: any;
+}
 
-});
+const rightsTickets = computed<TicketVM[]>(() =>
+    (list.value || []).map((item: any) => ({
+      value: stripZeros((Number(item.discount) / 10).toFixed(1)),
+      unit: "折",
+      typeLabel: "权益卡",
+      title: "抵扣充电服务费",
+      statusText: item.status == 0 ? "已失效" : "生效中",
+      disabled: item.status == 0,
+      endTimeText: endTimeText(item),
+      extra: `权益余额 ¥${fenToYuan(item.rightsBalance)}`,
+      activityId: item.activityId,
+    }))
+);
+
+const couponTickets = computed<TicketVM[]>(() =>
+    (couponList.value || []).map((item: any) => {
+      const full = item.couponType === "FullDiscount";
+      return {
+        value: full ? stripZeros(fenToYuan(item.discount)) : stripZeros((Number(item.discount) / 10).toFixed(1)),
+        unit: full ? "元" : "折",
+        typeLabel: full ? "满减券" : "折扣券",
+        title: "抵扣充电服务费",
+        statusText: item.status == 0 ? "已失效" : item.usageStatus == 1 ? "已使用" : "未使用",
+        disabled: item.status == 0 || item.usageStatus == 1,
+        endTimeText: endTimeText(item),
+        extra: `门槛 满 ¥${fenToYuan(item.minServiceMoney)} 可用`,
+        activityId: item.activityId,
+      };
+    })
+);
+
+const tabs = computed(() => [
+  {label: "优惠券", value: 1, count: couponTickets.value.length},
+  {label: "权益卡", value: 0, count: rightsTickets.value.length},
+]);
+
+const currentList = computed(() =>
+    mode.value === 0 ? rightsTickets.value : couponTickets.value
+);
+
+const toActivity = (item: TicketVM) => {
+  if (item.activityId) {
+    to(`/pages-common/activity/activity?id=${item.activityId}`);
+  }
+};
 </script>
 
 <style lang="scss" scoped>
 .page {
   min-height: 100vh;
   width: 100%;
-  background-color: #f6f7fa;
-
-  .item {
-    position: relative;
-    min-height: 220rpx;
-    border-radius: 20rpx;
-    overflow: hidden;
-    background: #fff;
-
-    .left {
-      flex-grow: 1;
-    }
-
-    .right {
-      flex-shrink: 0;
-
-      .btn {
-        border-radius: 8rpx;
-        border: 1px solid var(--color-primary);
-        color: var(--color-primary);
-        width: 144rpx;
-        height: 56rpx;
-      }
-    }
-  }
+  padding: 8rpx 30rpx 0;
+  background-color: var(--bg-page);
+}
 
-  .tab-list {
-    width: 100%;
+/* ============ tabs ============ */
+.tabs {
+  display: flex;
+  width: 100%;
+}
 
-    .w50 {
-      width: 50%;
-      text-align: center;
-    }
+.tab-item {
+  position: relative;
+  /* flex-basis 0 强制严格等分,不受文字宽度影响 */
+  flex: 1 1 0;
+  min-width: 0;
+  height: 96rpx;
+}
 
-    .active {
-      color: var(--color-primary);
-    }
-  }
+.tab-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 32rpx;
+  height: 32rpx;
+  padding: 0 10rpx;
+  margin-left: 10rpx;
+  border-radius: 16rpx;
+  background: var(--color-primary-tint-strong);
+  color: var(--color-primary);
+  font-size: 20rpx;
+  font-weight: 500;
+  box-sizing: border-box;
+}
+
+.tab-badge_off {
+  background: rgba(28, 43, 41, 0.06);
+  color: var(--text-3);
+}
+
+.tab-underline {
+  position: absolute;
+  left: 50%;
+  bottom: 4rpx;
+  transform: translateX(-50%);
+  width: 40rpx;
+  height: 6rpx;
+  border-radius: 6rpx;
+  background-color: var(--color-primary);
+  transition: opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+/* ============ 票券卡片 ============ */
+.ticket {
+  display: flex;
+  align-items: stretch;
+  min-height: 176rpx;
+  border-radius: 20rpx;
+  background: #fff;
+  overflow: hidden;
+  box-shadow: 0 2rpx 8rpx rgba(28, 43, 41, 0.04);
 }
 
-.item-status {
+.ticket-left {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 176rpx;
+  flex-shrink: 0;
+  background: var(--color-primary-tint);
+}
+
+.ticket-num {
+  font-size: 48rpx;
+  line-height: 52rpx;
+  font-weight: 600;
+  color: var(--color-primary);
+  font-variant-numeric: tabular-nums;
+}
+
+.ticket-unit {
   font-size: 24rpx;
-  padding: 0rpx 2rpx;
+  font-weight: 500;
   color: var(--color-primary);
-  border: 2rpx solid var(--color-primary);
-  border-radius: 6rpx;
+}
 
+.ticket-type {
+  margin-top: 8rpx;
+  font-size: 22rpx;
+  color: var(--color-primary);
+  opacity: 0.72;
 }
 
-.item-header__left {
-  display: inline-flex;
-  align-items: flex-end;
+.ticket-divider {
+  position: relative;
+  width: 0;
+  margin: 0 24rpx;
+  border-left: 2rpx dashed rgba(28, 43, 41, 0.12);
 }
 
-.item-header__right {
-  padding: 5rpx;
+.ticket-notch {
+  position: absolute;
+  left: -13rpx;
+  width: 24rpx;
+  height: 24rpx;
+  border-radius: 50%;
+  background: var(--bg-page);
+}
+
+.ticket-notch-top {
+  top: -12rpx;
+}
+
+.ticket-notch-bottom {
+  bottom: -12rpx;
+}
+
+.ticket-right {
   display: flex;
-  //justify-content: flex-end;
+  flex-direction: column;
+  flex: 1;
+  min-width: 0;
+  padding: 24rpx 24rpx 20rpx 0;
 }
 
-.inactive {
-  cursor: not-allowed;
+.ticket-title {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.ticket-status {
+  display: inline-flex;
+  align-items: center;
+  height: 40rpx;
+  padding: 0 14rpx;
+  border-radius: 12rpx;
+  background: var(--color-primary-tint);
+  color: var(--color-primary);
+  font-size: 22rpx;
+}
+
+.ticket-link {
+  display: inline-flex;
+  align-items: center;
+  margin-top: auto;
+  padding-top: 12rpx;
+  font-size: 24rpx;
+  font-weight: 500;
+  color: var(--color-primary);
+  align-self: flex-start;
+}
+
+/* 失效/已使用:整体置灰 */
+.ticket-inactive {
   pointer-events: none;
 
-  .item-status {
-    font-size: 24rpx;
-    padding: 0rpx 2rpx;
-    color: #ccc !important;
-    border: 2rpx solid #ccc;
-    border-radius: 6rpx;
+  .ticket-left {
+    background: var(--color-sec);
+  }
 
+  .ticket-num,
+  .ticket-unit,
+  .ticket-type,
+  .ticket-link {
+    color: var(--text-3);
   }
 
-  .color-primary {
-    color: #ccc !important;
+  .ticket-status {
+    background: rgba(28, 43, 41, 0.06);
+    color: var(--text-3);
   }
 }
 
-.item-content_left {
-  flex-grow: 1;
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-
+/* ============ 空状态 ============ */
+.empty {
+  padding: 120rpx 0 160rpx;
 }
 
-.item-content_right {
-  width: 120rpx;
-  display: flex;
-  align-items: center;
-  align-content: center;
+.empty-icon {
+  width: 112rpx;
+  height: 112rpx;
+  border-radius: 50%;
+  background: var(--color-primary-tint);
 }
 </style>

+ 1 - 1
charge-front/src/pages-common/faq/faq.vue

@@ -50,7 +50,7 @@
       <image
         class="width-96"
         mode="widthFix"
-        src="/static/images/contact-customer.png"
+        src="/static/images/contact-customer.svg"
       />
       <view class="mt-16 color-666 fs-28">电话客服</view>
     </view>

+ 1 - 0
charge-front/src/pages.json

@@ -91,6 +91,7 @@
           "style": {
             "navigationStyle": "default",
             "navigationBarTitleText": "我的卡包",
+            "navigationBarBackgroundColor": "#F4F7F6",
             "enablePullDownRefresh": true
           }
         }

BIN
charge-front/src/static/images/contact-customer.png


+ 13 - 0
charge-front/src/static/images/contact-customer.svg

@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
+  <!-- 扁平化电话客服图标:品牌青绿实底圆徽 + 白色客服耳机(居中对称,线宽统一) -->
+  <circle cx="48" cy="48" r="44" fill="#2D9E95"/>
+  <!-- 头带:半圆弧,两端收进耳罩 -->
+  <path d="M29 44v-4a19 19 0 0 1 38 0v4" fill="none" stroke="#FFFFFF" stroke-width="5.5" stroke-linecap="round"/>
+  <!-- 左右耳罩:胶囊形 -->
+  <rect x="24" y="40" width="12" height="21" rx="6" fill="#FFFFFF"/>
+  <rect x="60" y="40" width="12" height="21" rx="6" fill="#FFFFFF"/>
+  <!-- 话务杆:自左耳罩底部平滑弯出 -->
+  <path d="M30 61c0 7.5 6.5 11.5 14 11.5h4.5" fill="none" stroke="#FFFFFF" stroke-width="5" stroke-linecap="round"/>
+  <!-- 话筒端点 -->
+  <circle cx="54" cy="72.5" r="5" fill="#FFFFFF"/>
+</svg>

+ 35 - 0
common/src/main/java/com/kym/common/interceptor/UserIdMdcInterceptor.java

@@ -0,0 +1,35 @@
+package com.kym.common.interceptor;
+
+import cn.dev33.satoken.stp.StpUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.MDC;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * 将登录用户ID写入MDC(key: uid),便于按用户串联日志
+ *
+ * @author kym
+ */
+public class UserIdMdcInterceptor implements HandlerInterceptor {
+
+    private static final String UID = "uid";
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        try {
+            Object loginId = StpUtil.getLoginIdDefaultNull();
+            if (loginId != null) {
+                MDC.put(UID, String.valueOf(loginId));
+            }
+        } catch (Exception ignored) {
+            // 未登录或鉴权上下文异常时不影响请求
+        }
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        MDC.remove(UID);
+    }
+}

+ 228 - 0
database/8-dedupe-pay-log.sql

@@ -0,0 +1,228 @@
+-- ============================================================
+-- t_pay_log 重复商户订单号:诊断 + 去重(建唯一索引的前置步骤)
+--
+-- 【执行状态 · 2026-10-02】生产库残留的 10 组重复已由人工手工清理完毕。
+--   本文件不再需要执行,仅保留备查;若日后 8.sql 的【执行前检查 1】再次返回结果,
+--   可按本文件流程处理。
+--   【3.定损】只用于评估历史多入账金额,业务已决定不追溯、不追回,跳过即可。
+--   清理结果仍必须通过 8.sql 末尾的【上线后对账 1/2/3】验收(那属于数据正确性,与追款无关)。
+--
+-- 背景:8.sql 的执行前检查 1 返回了 10 条重复(同一 out_trade_no 有多行)。
+--       同一个 out_trade_no 只可能来自同一笔微信支付,多行意味着该笔回调被处理了多次,
+--       或有人工补录。必须先去重才能建 uk_pay_log_out_trade_no。
+--
+-- ⚠️ 顺序:先跑【1.诊断】→【2.判定】→【3.定损】→【4.备份并去重】→ 再回到 8.sql 建索引。
+-- ⚠️ 删除 pay_log 行 = 把"账本"改成唯一,**不等于**把多充进余额的钱收回;这两件事分开决策(见第 3 步)。
+-- ⚠️ 第 4 步只自动处理"同一用户 + 同一交易号 + 同一金额"的明确重复组,
+--    其余组会列出来要求人工判断,避免误删别人真实收款的记录。
+-- ============================================================
+
+
+-- ============================================================
+-- 【1.诊断】10 组重复的画像
+-- ============================================================
+SELECT g.out_trade_no,
+       g.cnt                                   AS `行数`,
+       g.tx_cnt                                AS `交易号种类`,
+       g.user_cnt                              AS `用户数`,
+       g.amount_cnt                            AS `金额种类`,
+       g.first_create                          AS `首次记录时间`,
+       g.last_create                           AS `末次记录时间`,
+       TIMESTAMPDIFF(SECOND, g.first_create, g.last_create) AS `间隔秒`,
+       g.ids,
+       g.txs
+FROM (
+    SELECT out_trade_no,
+           COUNT(*)                        AS cnt,
+           COUNT(DISTINCT transaction_id)  AS tx_cnt,
+           COUNT(DISTINCT user_id)         AS user_cnt,
+           COUNT(DISTINCT total)           AS amount_cnt,
+           MIN(create_time)                AS first_create,
+           MAX(create_time)                AS last_create,
+           GROUP_CONCAT(id ORDER BY id)             AS ids,
+           GROUP_CONCAT(transaction_id ORDER BY id) AS txs
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) g
+ORDER BY g.first_create;
+
+
+-- ============================================================
+-- 【2.判定】逐行看全字段(人工确认每条重复的成因,别盲删)
+-- ============================================================
+SELECT p.*
+FROM charge_app.t_pay_log p
+JOIN (
+    SELECT out_trade_no
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) d ON d.out_trade_no = p.out_trade_no
+ORDER BY p.out_trade_no, p.id;
+
+-- 判定口径:
+--   交易号种类 = 1 且 间隔秒很小(秒级)  → 同一笔回调被并发/重试处理了两次:真实资金只收了一次,
+--                                        多出的那行对应"多入账",保留 MIN(id),其余删除(第 3、4 步)
+--   交易号种类 = 1 且 间隔很大(天/月)    → 可能是事后人工补录(历史上的补单):
+--                                        先确认当时余额是否已补过,再决定删哪行
+--   交易号种类 > 1                       → 异常数据,逐条人工判断,不要批量删
+--   用户数 > 1                           → 订单号撞号(不同用户同一订单号),必须单独定损
+--   金额种类 > 1                         → 数据不一致,逐条人工判断
+
+-- 安全校验:确认没有其他表引用这些 pay_log 行
+-- (充值场景 wallet_detail.transaction_id 实际未写入,这里仍做一次兜底检查;有结果先处理引用再删)
+SELECT w.id, w.user_id, w.order_no, w.transaction_id
+FROM charge_app.t_wallet_detail w
+JOIN (
+    SELECT out_trade_no
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) d ON d.out_trade_no = w.order_no
+WHERE w.transaction_id IS NOT NULL;
+
+
+-- ============================================================
+-- 【3.定损】多记了多少、涉及谁、有没有叠加影响退款
+-- ============================================================
+-- 3.1 疑似多入账金额("多出的行数 × 单笔金额"估算,单位:分)
+SELECT g.user_id,
+       COUNT(*)                        AS `重复订单数`,
+       SUM((g.cnt - 1) * g.total_each) AS `疑似多入账金额分`
+FROM (
+    SELECT user_id, out_trade_no, COUNT(*) AS cnt, MIN(total) AS total_each
+    FROM charge_app.t_pay_log
+    GROUP BY user_id, out_trade_no
+    HAVING COUNT(*) > 1
+) g
+GROUP BY g.user_id;
+
+-- 3.2 受影响用户的当前余额与充值口径(去重前后对照,用于和用户/财务核对)
+--     为避免依赖窗口函数(兼容 MySQL 5.7 / MariaDB 10.2 以下),用 MIN(id) 自连接实现
+SELECT g.user_id,
+       g.`重复订单数`,
+       g.`疑似多入账金额分`,
+       a.balance                    AS `当前余额分`,
+       a.discount_amount            AS `不可退金额分`,
+       w.wallet_recharge_total      AS `钱包已确认充值分`,
+       p.pay_log_total_with_dup     AS `pay_log合计_含重复分`,
+       p.pay_log_total_dedup        AS `pay_log合计_去重后分`
+FROM (
+    SELECT user_id, COUNT(*) AS `重复订单数`, SUM((cnt - 1) * total_each) AS `疑似多入账金额分`
+    FROM (
+        SELECT user_id, out_trade_no, COUNT(*) AS cnt, MIN(total) AS total_each
+        FROM charge_app.t_pay_log
+        GROUP BY user_id, out_trade_no
+        HAVING COUNT(*) > 1
+    ) x
+    GROUP BY user_id
+) g
+LEFT JOIN charge_app.t_account a ON a.user_id = g.user_id
+LEFT JOIN (
+    SELECT user_id, SUM(amount) AS wallet_recharge_total
+    FROM charge_app.t_wallet_detail
+    WHERE type = 1 AND status = 1
+    GROUP BY user_id
+) w ON w.user_id = g.user_id
+LEFT JOIN (
+    SELECT p.user_id,
+           SUM(p.total) AS pay_log_total_with_dup,
+           SUM(CASE WHEN p.id = k.keep_id THEN p.total ELSE 0 END) AS pay_log_total_dedup
+    FROM charge_app.t_pay_log p
+    LEFT JOIN (
+        SELECT out_trade_no, MIN(id) AS keep_id
+        FROM charge_app.t_pay_log
+        GROUP BY out_trade_no
+    ) k ON k.out_trade_no = p.out_trade_no
+    WHERE p.trade_state = 'SUCCESS'
+    GROUP BY p.user_id
+) p ON p.user_id = g.user_id
+ORDER BY g.`疑似多入账金额分` DESC;
+
+-- 3.3 是否有人靠"虚高的充值合计"通过了退款校验(applyWxRefund 用 SUM(pay_log.total) 判断可退额度)
+--     若有重复记录用户成功退款的,需单独评估是否超退
+SELECT r.user_id, r.out_trade_no, r.total, r.refund, r.status, r.create_time
+FROM charge_app.t_refund_log r
+WHERE r.user_id IN (
+    SELECT user_id FROM charge_app.t_pay_log
+    GROUP BY user_id, out_trade_no HAVING COUNT(*) > 1
+)
+ORDER BY r.user_id, r.create_time;
+
+-- 说明:第 3 步只做"定损"。是否向用户追回多入账金额属于业务/财务决策,
+--       不要在未经确认的情况下直接扣减余额(余额被扣成负数会影响后续充电与退款判断)。
+
+
+-- ============================================================
+-- 【4.备份并去重】
+-- ============================================================
+-- 4.1 备份全部重复组(完整审计留痕;确认无误后再由人工决定是否清理该备份表)
+CREATE TABLE charge_app.t_pay_log_dup_bak_20261002 AS
+SELECT p.*
+FROM charge_app.t_pay_log p
+JOIN (
+    SELECT out_trade_no
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) d ON d.out_trade_no = p.out_trade_no;
+
+-- 4.2 需人工处理的"不安全组"(同一订单号跨用户/跨交易号/跨金额)——应为 0 行;
+--     若有结果,必须逐条人工确认后再决定,不要交给下面的自动删除
+SELECT out_trade_no,
+       COUNT(*)                       AS `行数`,
+       COUNT(DISTINCT user_id)        AS `用户数`,
+       COUNT(DISTINCT transaction_id) AS `交易号种类`,
+       COUNT(DISTINCT total)          AS `金额种类`,
+       GROUP_CONCAT(id ORDER BY id)   AS ids
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1
+   AND (COUNT(DISTINCT user_id) > 1
+     OR COUNT(DISTINCT transaction_id) > 1
+     OR COUNT(DISTINCT total) > 1);
+
+-- 4.3 待删除行明细核对(仅"安全组":同一用户 + 同一交易号 + 同一金额,保留 MIN(id))
+SELECT p.id, p.out_trade_no, p.user_id, p.total, p.transaction_id, p.success_time, p.create_time
+FROM charge_app.t_pay_log p
+JOIN (
+    SELECT out_trade_no, MIN(id) AS keep_id
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+       AND COUNT(DISTINCT user_id) = 1
+       AND COUNT(DISTINCT transaction_id) = 1
+       AND COUNT(DISTINCT total) = 1
+) d ON d.out_trade_no = p.out_trade_no
+WHERE p.id <> d.keep_id
+ORDER BY p.out_trade_no, p.id;
+
+-- 4.4 正式删除(用临时表承接 keep_id,绕开 MySQL "不能在同一语句里对目标表做子查询" 的限制 1093)
+CREATE TEMPORARY TABLE tmp_pay_log_keep AS
+SELECT out_trade_no, MIN(id) AS keep_id
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1
+   AND COUNT(DISTINCT user_id) = 1
+   AND COUNT(DISTINCT transaction_id) = 1
+   AND COUNT(DISTINCT total) = 1;
+
+DELETE p
+FROM charge_app.t_pay_log p
+JOIN tmp_pay_log_keep k ON k.out_trade_no = p.out_trade_no
+WHERE p.id <> k.keep_id;
+
+DROP TEMPORARY TABLE tmp_pay_log_keep;
+
+-- 4.5 复核:应为 0 行(若仍有行,说明都属于 4.2 的不安全组,需人工处理)
+SELECT out_trade_no, COUNT(*) AS cnt
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1;
+
+
+-- ============================================================
+-- 【5.回到 8.sql】
+-- ============================================================
+-- 4.5 返回 0 行后,再执行 database/8.sql 建唯一索引 uk_pay_log_out_trade_no。

+ 63 - 0
database/8-incident-20261001-compensation.sql

@@ -0,0 +1,63 @@
+-- ============================================================
+-- 【一次性人工补偿】2026-10-01 微信充值 30 元未到账事故
+--
+-- 事故:商户订单号 202610012106065931549256,30.00 元
+--       微信交易号 4500000442202610019536073654,支付成功时间 2026-10-01 21:06:14
+--       用户 user_id = 1555324358568706048(手机号 15814577840)
+--       微信支付回调被 Redis 幂等锁误判为"重复通知"后应答 HTTP 200,
+--       微信不再重试,钱包流水一直停留在"待确认",余额未增加。
+--
+-- 说明:本文件只针对本次事故执行一次,不属于版本迁移(勿并入 8.sql 自动执行)。
+--       执行前请先跑【0.核对】,并在微信商户平台确认该笔订单确已收款。
+-- ============================================================
+
+-- 【0.核对】应返回 1 行、status = 0(待确认)
+SELECT id, user_id, type, order_no, amount, status, create_time
+FROM charge_app.t_wallet_detail
+WHERE order_no = '202610012106065931549256';
+
+-- 【0.核对】应返回 0 行(证明系统内确实没有入账记录)
+SELECT id FROM charge_app.t_pay_log WHERE out_trade_no = '202610012106065931549256';
+
+-- 【1.钱包流水置为已确认】status: 0待确认 -> 1已确认
+UPDATE charge_app.t_wallet_detail
+SET status = 1,
+    currency = 'CNY',
+    amount = 3000,
+    transaction_time = '2026-10-01 21:06:14'
+WHERE id = 1555324958656167936
+  AND order_no = '202610012106065931549256'
+  AND status = 0;
+
+-- 【2.补记支付记录】必须补:幂等快路径依赖 t_pay_log,
+--     缺了它以后再收到该订单回调会走不到快路径而重复入账
+--     <唯一ID> 请替换为雪花ID或任意未被占用的唯一值
+INSERT INTO charge_app.t_pay_log
+    (id, user_id, openid, bank_type, mch_id, out_trade_no, transaction_id, success_time,
+     trade_type, trade_state, attach, total, currency, payer_total, payer_currency)
+VALUES
+    (<唯一ID>, 1555324358568706048, 'oK-oR5g5Y9vkVeaa7wsoLt1yMGAY', 'BANK_CREDIT', '1635831469',
+     '202610012106065931549256', '4500000442202610019536073654', '2026-10-01 21:06:14',
+     'JSAPI', 'SUCCESS', '', 3000, 'CNY', 3000, 'CNY');
+
+-- 【3.补余额】
+UPDATE charge_app.t_account
+SET balance = balance + 3000
+WHERE user_id = 1555324358568706048;
+
+-- 【4.验证】
+SELECT u.id AS user_id, acc.balance, acc.discount_amount,
+       (acc.balance - acc.discount_amount) AS refundable_amount
+FROM charge_app.t_user u
+LEFT JOIN charge_app.t_account acc ON u.id = acc.user_id
+WHERE u.id = 1555324358568706048;
+
+-- 【5.全量排查同类受害者】列出当天所有"待确认"的充值流水,
+--     再与微信对账单逐笔核对(未支付就放弃的预下单也会是待确认,需用对账单确认哪些真的付了钱)
+SELECT w.id, w.user_id, w.order_no, w.amount, w.status, w.create_time
+FROM charge_app.t_wallet_detail w
+WHERE w.type = 1
+  AND w.status = 0
+  AND w.create_time >= '2026-10-01 00:00:00'
+  AND w.create_time < '2026-10-02 00:00:00'
+ORDER BY w.create_time;

+ 4 - 0
database/8-rollback.sql

@@ -0,0 +1,4 @@
+-- 回滚:移除支付回调幂等唯一索引
+-- 注意:回滚后代码层的 Redis 锁 + payLog 快路径仍是唯一防线,但不具备并发兜底能力,
+--       并发重复投递存在重复入账风险,仅在索引导致线上问题时才回滚。
+ALTER TABLE charge_app.t_pay_log DROP INDEX uk_pay_log_out_trade_no;

+ 56 - 0
database/8.sql

@@ -0,0 +1,56 @@
+-- ============================================================
+-- 支付回调幂等加固:t_pay_log.out_trade_no 唯一索引
+--
+-- 事故背景:2026-10-01 21:06 商户订单号 202610012106065931549256(30 元)
+--   微信支付回调被 Redis 幂等锁误判为"重复通知",代码在未做任何入账的情况下
+--   应答了 HTTP 200,微信认为处理成功即停止重试,用户充值永久丢失。
+--   修复后:Redis 锁仅用于削峰,幂等的最终保证是本唯一索引
+--   (并发投递时只有一个事务能落库,落库失败的一方整体回滚,不会重复加钱)。
+-- ============================================================
+
+-- 【执行前检查 1】是否已存在重复的商户订单号(正常应为 0 行;有结果必须先人工核对处理)
+--   若本查询有结果(当前生产库为 10 条),请先执行 database/8-dedupe-pay-log.sql
+--   完成"诊断 → 定损 → 备份 → 去重",返回 0 行后再继续本文件。
+SELECT out_trade_no, COUNT(*) AS cnt, MIN(id) AS keep_id, GROUP_CONCAT(id) AS ids
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1;
+
+-- 【执行前检查 2】当前索引情况(若已存在 uk_pay_log_out_trade_no 则无需重复执行变更)
+SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'charge_app'
+  AND TABLE_NAME = 't_pay_log';
+
+-- 【正式变更】商户订单号唯一(微信侧 out_trade_no 本身唯一)
+ALTER TABLE charge_app.t_pay_log
+    ADD UNIQUE KEY uk_pay_log_out_trade_no (out_trade_no);
+
+-- 【验证】NON_UNIQUE 应为 0
+SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'charge_app'
+  AND TABLE_NAME = 't_pay_log'
+  AND INDEX_NAME = 'uk_pay_log_out_trade_no';
+
+-- 【上线后对账 1】唯一性:应返回 0 行
+SELECT out_trade_no, COUNT(*) AS cnt
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1;
+
+-- 【上线后对账 2】正向:已确认的充值流水必须都能在 t_pay_log 找到同一订单号,应返回 0 行
+--   有结果 = 该笔充值缺少支付记录(可能被清理误删,或历史人工补单),需逐条确认后补齐 pay_log,
+--   否则后续再收到该订单回调会走不到幂等快路径而重复入账
+SELECT w.id, w.user_id, w.order_no, w.amount, w.status, w.create_time
+FROM charge_app.t_wallet_detail w
+LEFT JOIN charge_app.t_pay_log p ON p.out_trade_no = w.order_no
+WHERE w.type = 1 AND w.status = 1 AND p.id IS NULL;
+
+-- 【上线后对账 3】反向:成功的支付记录必须都能找到对应的已确认充值流水,应返回 0 行
+--   有结果 = 微信收了钱但钱包流水没确认(本次事故就是这一类),需按 8-incident-*.sql 的方式补单
+SELECT p.id, p.user_id, p.out_trade_no, p.total, p.transaction_id, p.success_time
+FROM charge_app.t_pay_log p
+LEFT JOIN charge_app.t_wallet_detail w
+       ON w.order_no = p.out_trade_no AND w.type = 1 AND w.status = 1
+WHERE p.trade_state = 'SUCCESS' AND w.id IS NULL;

+ 10 - 4
miniapp/src/main/java/com/kym/miniapp/aspect/AppLogAspect.java

@@ -51,10 +51,16 @@ public class AppLogAspect {
     @Around("logPointCut()")
     Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
         var startTime = System.currentTimeMillis();
-        Object result = joinPoint.proceed();
-        var executeTime = System.currentTimeMillis() - startTime;
-        saveApiLog(joinPoint, executeTime);
-        return result;
+        try {
+            Object result = joinPoint.proceed();
+            saveApiLog(joinPoint, System.currentTimeMillis() - startTime);
+            return result;
+        } catch (Throwable e) {
+            // 失败请求同样落审计日志,并以ERROR记录堆栈
+            LOGGER.error("ApiLog:方法{}执行异常,耗时{}ms", joinPoint.getSignature().toShortString(), System.currentTimeMillis() - startTime, e);
+            saveApiLog(joinPoint, System.currentTimeMillis() - startTime);
+            throw e;
+        }
     }
 
 

+ 4 - 0
miniapp/src/main/java/com/kym/miniapp/config/SaTokenConfigure.java

@@ -3,6 +3,7 @@ package com.kym.miniapp.config;
 import cn.dev33.satoken.interceptor.SaInterceptor;
 import cn.dev33.satoken.router.SaRouter;
 import cn.dev33.satoken.stp.StpUtil;
+import com.kym.common.interceptor.UserIdMdcInterceptor;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -55,5 +56,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
                             .check(r -> StpUtil.checkLogin());
                 }))
                 .addPathPatterns("/**");
+        // 将登录用户ID写入MDC,便于按用户串联日志
+        registry.addInterceptor(new UserIdMdcInterceptor())
+                .addPathPatterns("/**");
     }
 }

+ 41 - 6
miniapp/src/main/resources/logback-spring.xml

@@ -2,22 +2,56 @@
 <configuration scan="true" scanPeriod="60 seconds" debug="false">
     <contextName>logback</contextName>
     <property name="log.path" value="logs/miniapp/miniapp.log"/>
-    <property name="CONSOLE_LOG_PATTERN"
-              value="%date{yyyy-MM-dd HH:mm:ss} | %highlight(%-5level) | %boldYellow(%thread) | %boldGreen(%logger) | %msg%n"/>
+    <property name="error.path" value="logs/miniapp/miniapp-error.log"/>
+    <property name="FILE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%X{uid}|%msg%n"/>
     <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
         <encoder>
-            <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%msg%n</pattern>
+            <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%X{uid}|%msg%n</pattern>
         </encoder>
     </appender>
     <appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <file>${log.path}</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
             <fileNamePattern>./logs/miniapp/miniapp-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>10GB</totalSizeCap>
         </rollingPolicy>
         <encoder>
-            <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%msg%n</pattern>
+            <pattern>${FILE_PATTERN}</pattern>
         </encoder>
     </appender>
+    <!-- ERROR单独落盘,便于告警对接与问题定位 -->
+    <appender name="errorFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>ERROR</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+        <file>${error.path}</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>./logs/miniapp/miniapp-error-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>2GB</totalSizeCap>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${FILE_PATTERN}</pattern>
+        </encoder>
+    </appender>
+    <!-- 异步写盘;includeCallerData必须开启,否则%F:%L打印为空 -->
+    <appender name="asyncFile" class="ch.qos.logback.classic.AsyncAppender">
+        <queueSize>1024</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+        <neverBlock>true</neverBlock>
+        <includeCallerData>true</includeCallerData>
+        <appender-ref ref="file"/>
+    </appender>
+    <appender name="asyncErrorFile" class="ch.qos.logback.classic.AsyncAppender">
+        <queueSize>1024</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+        <neverBlock>true</neverBlock>
+        <includeCallerData>true</includeCallerData>
+        <appender-ref ref="errorFile"/>
+    </appender>
     <!-- <logger name="org.springframework.web" level="DEBUG"/> -->
     <logger name="org.springframework" level="info"/>
     <logger name="org.hibernate" level="info"/>
@@ -32,6 +66,7 @@
     <logger name="io.lettuce" level="warn"/>
     <root level="debug">
         <appender-ref ref="console"/>
-        <appender-ref ref="file"/>
+        <appender-ref ref="asyncFile"/>
+        <appender-ref ref="asyncErrorFile"/>
     </root>
-</configuration>
+</configuration>

+ 116 - 64
service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -47,12 +47,15 @@ import lombok.SneakyThrows;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.core.io.ClassPathResource;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.interceptor.TransactionAspectSupport;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.io.ByteArrayOutputStream;
 import java.io.File;
@@ -298,79 +301,98 @@ public class WxPayServiceImpl implements WxPayService {
             var notifyRes = handleWxNotify(request);
             // 以支付通知回调为例,验签、解密并转换成 Transaction
             Transaction transaction = ((NotificationParser) notifyRes[1]).parse((RequestParam) notifyRes[0], Transaction.class);
-            LOGGER.info("微信支付回调{}:验签解密完毕,数据:\n{}", notifyRes[2], transaction);
+            LOGGER.info("微信支付回调{}:验签解密完毕,outTradeNo:{},金额:{}分,交易状态:{},支付时间:{}",
+                    notifyRes[2], transaction.getOutTradeNo(),
+                    transaction.getAmount() == null ? null : transaction.getAmount().getTotal(),
+                    transaction.getTradeState(), transaction.getSuccessTime());
             // 判断是否已经接收处理过通知(快路径)
+            // 注意:只有确认"该笔充值已入账"才允许应答 200。微信只认 200/204 为处理成功,
+            // 收到成功应答即停止重试(重试阶梯 15s/15s/30s/3m…),因此任何"未入账却回 200"的
+            // 分支都会造成用户资金永久丢失。
             if (payLogService.lambdaQuery().eq(PayLog::getOutTradeNo, transaction.getOutTradeNo()).one() != null) {
                 return ResponseEntity.status(HttpStatus.OK).build();
             }
 
-            // Redis锁防并发重复处理(微信会对未应答的通知重试)
+            // Redis锁仅用于削减并发(微信会对未应答的通知重试);
+            // 幂等的最终保证是 t_pay_log.out_trade_no 唯一索引,锁失效不会导致重复入账
             String notifyLockKey = RedisKeys.PAY_NOTIFY_LOCK + transaction.getOutTradeNo();
             Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(notifyLockKey, "1", 30, TimeUnit.SECONDS);
             if (Boolean.FALSE.equals(locked)) {
-                LOGGER.info("微信支付回调{}:订单{}正在处理中,忽略本次重复通知", notifyRes[2], transaction.getOutTradeNo());
-                return ResponseEntity.status(HttpStatus.OK).build();
+                // 未抢到锁:该订单的另一次投递正在处理,或存在残留锁。
+                // 此处绝不能回 200:本次没有完成任何入账,回 200 会让微信停止重试,充值将永久丢失。
+                // 返回 500 让微信按策略重试:重试时要么命中上面的已入账快路径,要么在锁过期后正常入账,
+                // 最坏只是延迟到账,不会丢单。
+                LOGGER.warn("微信支付回调{}:订单{}正在处理中,本次未入账,返回失败等待微信重试", notifyRes[2], transaction.getOutTradeNo());
+                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                        .body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理中,等待微信重试"));
             }
-            try {
-                DateTime dt = DateUtil.parse(transaction.getSuccessTime());
-                LocalDateTime successTime = LocalDateTimeUtil.of(dt);
-
-                // 钱包流水
-                var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
-                if (walletDetail != null) {
-                    walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
-                    walletDetail.setCurrency(transaction.getAmount().getCurrency());
-                    walletDetail.setAmount(transaction.getAmount().getTotal());
-                    walletDetail.setTransactionTime(successTime);
-                    walletDetailService.updateById(walletDetail);
-
-                    // 更新余额
-                    accountService.lambdaUpdate().setSql("balance = (balance+%d)".formatted(transaction.getAmount().getTotal()))
-                            .eq(Account::getUserId, walletDetail.getUserId()).update();
-
-                    // 异步处理充值服务费打折权益活动相关逻辑
-                    activityService.handleRechargeActivity(walletDetail.getUserId(), transaction.getAmount().getTotal());
-
-                    // 支付记录
-                    var payLog = new PayLog();
-                    payLog.setUserId(walletDetail.getUserId());
-                    payLog.setOpenid(transaction.getPayer().getOpenid());
-                    payLog.setBankType(transaction.getBankType());
-                    payLog.setMchId(transaction.getMchid());
-                    payLog.setOutTradeNo(transaction.getOutTradeNo());
-                    payLog.setTransactionId(transaction.getTransactionId());
-                    payLog.setSuccessTime(successTime);
-                    payLog.setTradeType(transaction.getTradeType().name());
-                    payLog.setTradeState(transaction.getTradeState().name());
-                    payLog.setAttach(transaction.getAttach());
-                    payLog.setTotal(transaction.getAmount().getTotal());
-                    payLog.setCurrency(transaction.getAmount().getCurrency());
-                    payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
-                    payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
+            // 锁在事务提交成功后才释放;回滚/异常时保留锁由 TTL(30s) 过期,避免并发重复入账
+            releaseNotifyLockAfterCommit(notifyLockKey);
+            DateTime dt = DateUtil.parse(transaction.getSuccessTime());
+            LocalDateTime successTime = LocalDateTimeUtil.of(dt);
+
+            // 钱包流水
+            var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
+            if (walletDetail != null) {
+                walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
+                walletDetail.setCurrency(transaction.getAmount().getCurrency());
+                walletDetail.setAmount(transaction.getAmount().getTotal());
+                walletDetail.setTransactionTime(successTime);
+                walletDetailService.updateById(walletDetail);
+
+                // 更新余额
+                accountService.lambdaUpdate().setSql("balance = (balance+%d)".formatted(transaction.getAmount().getTotal()))
+                        .eq(Account::getUserId, walletDetail.getUserId()).update();
+
+                // 异步处理充值服务费打折权益活动相关逻辑
+                activityService.handleRechargeActivity(walletDetail.getUserId(), transaction.getAmount().getTotal());
+
+                // 支付记录
+                var payLog = new PayLog();
+                payLog.setUserId(walletDetail.getUserId());
+                payLog.setOpenid(transaction.getPayer().getOpenid());
+                payLog.setBankType(transaction.getBankType());
+                payLog.setMchId(transaction.getMchid());
+                payLog.setOutTradeNo(transaction.getOutTradeNo());
+                payLog.setTransactionId(transaction.getTransactionId());
+                payLog.setSuccessTime(successTime);
+                payLog.setTradeType(transaction.getTradeType().name());
+                payLog.setTradeState(transaction.getTradeState().name());
+                payLog.setAttach(transaction.getAttach());
+                payLog.setTotal(transaction.getAmount().getTotal());
+                payLog.setCurrency(transaction.getAmount().getCurrency());
+                payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
+                payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
+                // t_pay_log.out_trade_no 唯一索引兜底:并发投递时只有一个事务能落库,
+                // 落库失败的这次整体回滚(含重复增加的余额),避免重复入账
+                try {
                     payLogService.save(payLog);
-                    LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
-
-                    // 如果存在进行中的订单,则向en+更新用户余额(朗新不支持此操作)
-                    // 平台同步为附加操作,失败时不能回滚充值事务,仅记录日志待补偿
-                    try {
-                        var chargingOrder = chargeOrderService.getChargingOrderByUserId(walletDetail.getUserId());
-                        if (chargingOrder != null && "EN_PLUS".equals(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()))) {
-                            var account = accountService.getAccountByUserId(walletDetail.getUserId());
-                            // TODO: 2023-11-30 快充这里考虑过充的金额要提高
-                            var res = platformApiService.updateBalanceByQueryEquipChargeStatus(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()), chargingOrder.getStartChargeSeq(), account.getBalance() - 50);
-                            LOGGER.info("用户:{}充电过程中充值,已更新EN_PLUS充电金额,en+返回数据:{}", account.getUserId(), res);
-                        }
-                    } catch (Exception e) {
-                        LOGGER.error("微信支付回调{}:充值成功,但同步EN_PLUS平台余额失败,用户:{}", notifyRes[2], walletDetail.getUserId(), e);
-                    }
+                } catch (DuplicateKeyException e) {
+                    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
+                    LOGGER.warn("微信支付回调{}:订单{}已存在支付记录,判定为重复通知,本次回滚不入账",
+                            notifyRes[2], transaction.getOutTradeNo());
                     return ResponseEntity.status(HttpStatus.OK).build();
-
-                } else {
-                    LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
-                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
                 }
-            } finally {
-                stringRedisTemplate.delete(notifyLockKey);
+                LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
+
+                // 如果存在进行中的订单,则向en+更新用户余额(朗新不支持此操作)
+                // 平台同步为附加操作,失败时不能回滚充值事务,仅记录日志待补偿
+                try {
+                    var chargingOrder = chargeOrderService.getChargingOrderByUserId(walletDetail.getUserId());
+                    if (chargingOrder != null && "EN_PLUS".equals(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()))) {
+                        var account = accountService.getAccountByUserId(walletDetail.getUserId());
+                        // TODO: 2023-11-30 快充这里考虑过充的金额要提高
+                        var res = platformApiService.updateBalanceByQueryEquipChargeStatus(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()), chargingOrder.getStartChargeSeq(), account.getBalance() - 50);
+                        LOGGER.info("用户:{}充电过程中充值,已更新EN_PLUS充电金额,en+返回数据:{}", account.getUserId(), res);
+                    }
+                } catch (Exception e) {
+                    LOGGER.error("微信支付回调{}:充值成功,但同步EN_PLUS平台余额失败,用户:{}", notifyRes[2], walletDetail.getUserId(), e);
+                }
+                return ResponseEntity.status(HttpStatus.OK).build();
+
+            } else {
+                LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
+                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
             }
         } catch (Exception e) {
             // 标记事务回滚,防止异常时部分提交
@@ -378,12 +400,42 @@ public class WxPayServiceImpl implements WxPayService {
             if (e instanceof ValidationException) {
                 // 签名验证失败,返回 401 UNAUTHORIZED 状态码
                 LOGGER.error("微信支付通知验签失败", e);
+                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", HttpStatus.UNAUTHORIZED, "message", "验签失败"));
             }
-            if (e instanceof BusinessException) {
-                LOGGER.error("业务异常", e);
+            // 业务/系统异常返回 500,微信会按策略重试;与验签失败区分便于监控告警
+            if (e instanceof BusinessException biz) {
+                LOGGER.error("微信支付回调业务异常:{}", biz.getMessage(), biz);
+            } else {
+                LOGGER.error("微信支付回调处理异常", e);
             }
-            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", HttpStatus.UNAUTHORIZED, "message", "验签失败"));
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理失败,等待微信重试"));
+        }
+    }
+
+    /**
+     * 支付回调 Redis 锁:仅在事务成功提交后释放。
+     * <p>
+     * 原实现把删除放在 finally 中,而 finally 先于 {@link Transactional} 的提交执行,
+     * 锁并没有覆盖"入账 + 提交"这段临界区:临界区内到达的重复通知能穿过尚未提交的
+     * payLog 快路径再次入账(重复加钱)。改为提交后释放后,锁真正覆盖了整个临界区。
+     * <p>
+     * 回滚/异常时故意不删除,让锁随 TTL(30s) 自然过期:最坏结果是微信重试被推迟,
+     * 而不会出现重复入账或丢单。
+     */
+    private void releaseNotifyLockAfterCommit(String notifyLockKey) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            // 理论上不会走到这里(wxNotify 带 @Transactional),兜底为立即释放
+            stringRedisTemplate.delete(notifyLockKey);
+            return;
         }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCompletion(int status) {
+                if (status == TransactionSynchronization.STATUS_COMMITTED) {
+                    stringRedisTemplate.delete(notifyLockKey);
+                }
+            }
+        });
     }
 
     /**