42 Incheckningar 21f827bfb4 ... 6fea128ac2

Upphovsman SHA1 Meddelande Datum
  skyline 6fea128ac2 fix: 结束洗车通知剩余金额改为消费后余额 3 veckor sedan
  skyline 6dd45526cb fix: 支付回调幂等门闩移到余额更新之前 3 veckor sedan
  skyline 780946e2aa fix: 资金操作接口增加权限校验,低权限账号不再能操作资金 3 veckor sedan
  skyline 980ff6b712 fix: 结算确认增加条件更新门闩,并发重复确认不再双倍入账 3 veckor sedan
  skyline f549d96e45 fix: 提现申请改为原子条件扣减,杜绝并发超提与负数金额 3 veckor sedan
  skyline f229ed8cf6 fix: 充值归属站点改为服务端确定,客户端传值不再生效 3 veckor sedan
  skyline 71367dda87 fix: 申请退款增加账户行锁,杜绝并发双退 3 veckor sedan
  skyline dac5c3ef92 fix: 微信退款回调幂等与兜底 3 veckor sedan
  skyline 47def8b074 fix: 订单结算增加条件更新门闩,杜绝并发双重扣款 3 veckor sedan
  skyline 936fc87f86 fix: 微信支付回调幂等改造,唯一索引兜底并发重复投递 3 veckor sedan
  skyline 310bde8039 fix: 资金关键表增加唯一索引,为支付/退款/结算提供幂等兜底 3 veckor sedan
  skyline 27d0107946 fix: 统计详情弹窗改为3列网格布局,与列表统一 1 månad sedan
  skyline 97b158d263 fix: 统计数据列表改为3列2行排版 1 månad sedan
  skyline 11a5bc7191 feat: 结算记录列表增加核心数据字段展示 1 månad sedan
  skyline 0d6a58ee8e fix: 结算记录列表 createTime 类型转换,LocalDateTime → String 1 månad sedan
  skyline 09f1e92146 fix: formatTime 增加空值保护,避免 NaN-NaN 显示 1 månad sedan
  skyline 516f7f2b89 fix: 站点账户卡片头部余额字段修正为 availableBalance 1 månad sedan
  skyline b7dcd658fd fix: 财务管理站点账户卡片改为显示账户信息而非营收信息 1 månad sedan
  skyline f79efbf24e fix: 登录失败时 SysLogAspect 调用 StpUtil.getSession() 抛出异常覆盖原始响应 1 månad sedan
  skyline 022f84b19a fix: SaTokenConfigure 增加请求路径直接判断作为兜底防护 1 månad sedan
  skyline 57f6487e90 fix: SaTokenConfigure 增加 @SaIgnore 注解检查,与 miniapp 模块保持一致 1 månad sedan
  skyline ab9b7a30c3 fix: 重构登录错误提示逻辑,彻底修复错误信息显示问题 1 månad sedan
  skyline 38e66f6846 fix: 修复登录失败提示不准确的问题 1 månad sedan
  skyline f39efa9ffb fix: 登录页手机号或密码错误时显示正确的后端提示信息 1 månad sedan
  skyline d39420198a fix: 走势图总金额改为订单实收金额,月统计隐藏汇总卡片 1 månad sedan
  skyline 18093bb5b7 fix: 修复设备离线提醒丢失问题 1 månad sedan
  skyline f62d777995 style: 备案号字体改为白色 1 månad sedan
  skyline a1fadc1c74 feat: 登录页底部添加网站备案号 粤ICP备2025408522号-2 1 månad sedan
  skyline 802aa8cf51 chore: 月度结算定时任务改为每月5日 15:30 执行 1 månad sedan
  skyline 6b2061a82f style: 统计页站点选择器和日期输入框高度统一 1 månad sedan
  skyline 7b9502f0ce style: 数据统计页汇总卡片、列表项、详情弹窗数据全部居中显示 1 månad sedan
  skyline cb655ffc7f feat: 日统计/月统计列表卡片新增注册用户和活跃用户 1 månad sedan
  skyline adffc38109 fix: 日统计汇总活跃用户改为跨天去重计算 1 månad sedan
  skyline 45873f8b5d feat: admin-h5 全局文本可选 + 关键字段一键复制 1 månad sedan
  skyline 95e050d862 feat: 结算确认步骤 — 待结算金额需财务确认后才转入站点可提现余额 1 månad sedan
  skyline 4b820dc9ca fix: 修正结算公式,提现手续费在平台费之前扣除 1 månad sedan
  skyline 3f969cd3d1 feat: 结算记录新增微信提现手续费(0.6%),结算金额扣减该费用 1 månad sedan
  skyline 59e39179f4 fix: 结算记录站点列改为居中上下结构,名称在上ID在下,中间分隔线 1 månad sedan
  skyline c3879d6e2f fix: 结算状态改为待结算、日志按日压缩归档、站点列显示优化 1 månad sedan
  skyline d5ef876d8a fix: 调度线程池改为虚拟线程模式,防止单线程阻塞导致定时任务遗漏 1 månad sedan
  skyline 8abd70693c chore: gitignore 忽略 AI/Agent 工具生成文件 1 månad sedan
  skyline 4b2f2317bc feat: 首页看板增加洗车人数和今日退款,修复公众号客服浮标点击无反应 1 månad sedan
58 ändrade filer med 1063 tillägg och 288 borttagningar
  1. 6 0
      .gitignore
  2. 6 4
      CLAUDE.md
  3. 8 0
      admin-h5/src/App.vue
  4. 24 3
      admin-h5/src/pages/device/detail.vue
  5. 13 2
      admin-h5/src/pages/device/list.vue
  6. 8 8
      admin-h5/src/pages/finance/index.vue
  7. 25 5
      admin-h5/src/pages/finance/settlement.vue
  8. 13 2
      admin-h5/src/pages/finance/split-record.vue
  9. 17 1
      admin-h5/src/pages/index/index.vue
  10. 2 2
      admin-h5/src/pages/login/login.vue
  11. 31 4
      admin-h5/src/pages/order/detail.vue
  12. 16 9
      admin-h5/src/pages/order/list.vue
  13. 102 75
      admin-h5/src/pages/statistics/index.vue
  14. 23 6
      admin-h5/src/pages/user/list.vue
  15. 20 0
      admin-h5/src/utils/index.js
  16. 4 1
      admin-h5/src/utils/request.js
  17. 5 0
      admin-web-new/src/api/finance.ts
  18. 28 0
      admin-web-new/src/style/login.css
  19. 59 6
      admin-web-new/src/views/admin/finance/settlement.vue
  20. 5 0
      admin-web-new/src/views/login/index.vue
  21. 52 3
      admin-web/src/views/admin/finance/settlement.vue
  22. 5 5
      admin-web/src/views/admin/index.vue
  23. 22 1
      admin-web/src/views/login/index.vue
  24. 6 4
      car-wash-admin/src/main/java/com/kym/admin/aspect/SysLogAspect.java
  25. 25 2
      car-wash-admin/src/main/java/com/kym/admin/config/SaTokenConfigure.java
  26. 30 0
      car-wash-admin/src/main/java/com/kym/admin/config/SchedulingConfig.java
  27. 2 0
      car-wash-admin/src/main/java/com/kym/admin/controller/AdminUserController.java
  28. 19 0
      car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java
  29. 2 0
      car-wash-admin/src/main/java/com/kym/admin/jobs/DeviceHealthProbeJob.java
  30. 2 0
      car-wash-admin/src/main/java/com/kym/admin/jobs/FaultNotificationJob.java
  31. 4 2
      car-wash-admin/src/main/java/com/kym/admin/jobs/SettlementJob.java
  32. 2 0
      car-wash-admin/src/main/java/com/kym/admin/jobs/StaleOrderReconciliationJob.java
  33. 5 2
      car-wash-admin/src/main/java/com/kym/admin/jobs/StatJob.java
  34. 6 2
      car-wash-admin/src/main/resources/logback-spring.xml
  35. 1 0
      car-wash-common/src/main/java/com/kym/common/utils/wx/WxPbUtil.java
  36. 5 0
      car-wash-entity/src/main/java/com/kym/entity/SettlementRecord.java
  37. 10 0
      car-wash-entity/src/main/java/com/kym/entity/vo/DashboardVo.java
  38. 1 0
      car-wash-entity/src/main/java/com/kym/entity/vo/SettlementRecordVo.java
  39. 9 0
      car-wash-entity/src/main/resources/sql/v18_add_settlement_company_id.sql
  40. 9 0
      car-wash-entity/src/main/resources/sql/v19_add_settlement_withdrawal_fee.sql
  41. 69 0
      car-wash-entity/src/main/resources/sql/v20_add_finance_idempotency_indexes.sql
  42. 52 0
      car-wash-entity/src/main/resources/sql/v21_add_settlement_modify_permission.sql
  43. 6 7
      car-wash-miniapp/src/main/java/com/kym/miniapp/controller/PaymentController.java
  44. 6 2
      car-wash-miniapp/src/main/resources/logback-spring.xml
  45. 32 16
      car-wash-mp/src/components/custom-service/index.vue
  46. 5 0
      car-wash-service/src/main/java/com/kym/service/SettlementService.java
  47. 5 0
      car-wash-service/src/main/java/com/kym/service/WashOrderService.java
  48. 3 1
      car-wash-service/src/main/java/com/kym/service/aliyun/lot/AliyunLotClient.java
  49. 3 0
      car-wash-service/src/main/java/com/kym/service/impl/AdminUserServiceImpl.java
  50. 8 1
      car-wash-service/src/main/java/com/kym/service/impl/DailyStatServiceImpl.java
  51. 21 11
      car-wash-service/src/main/java/com/kym/service/impl/FaultNotificationServiceImpl.java
  52. 8 4
      car-wash-service/src/main/java/com/kym/service/impl/OrderSettlementServiceImpl.java
  53. 81 41
      car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java
  54. 9 0
      car-wash-service/src/main/java/com/kym/service/impl/StatServiceImpl.java
  55. 12 3
      car-wash-service/src/main/java/com/kym/service/impl/StationAccountServiceImpl.java
  56. 11 0
      car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java
  57. 2 2
      car-wash-service/src/main/java/com/kym/service/wechat/WxPayService.java
  58. 98 51
      car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

+ 6 - 0
.gitignore

@@ -37,3 +37,9 @@ car-wash-admin/src/main/resources/static/
 admin-h5/node_modules/
 admin-h5/dist/
 .qoder/
+
+### AI / Agent tooling ###
+.agents/
+.impeccable/
+.trae/
+skills-lock.json

+ 6 - 4
CLAUDE.md

@@ -50,12 +50,14 @@ car-wash-java/
 
 **月结公式**:
 ```
-feeBase = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome
-platformFee = feeBase × 10%
-available = openingBalance + feeBase - platformFee
+netInflow     = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome
+withdrawalFee = netInflow × 0.6%(微信先扣,仅对货物流转净额)
+afterWechat   = openingBalance + netInflow - withdrawalFee
+platformFee   = afterWechat × 10%
+settlementAmount = afterWechat - platformFee
 ```
 
-`available > 0` 时正常结算,转入站点可提现余额,同时记录平台服务费。负数则结转下期。
+`settlementAmount > 0` 时正常结算,转入站点可提现余额,同时记录平台服务费。负数则结转下期。
 
 ## 关键业务链路
 

+ 8 - 0
admin-h5/src/App.vue

@@ -31,6 +31,14 @@ page {
   background-color: $uni-page-bg-color;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
+  -webkit-user-select: text;
+  user-select: text;
+}
+
+/* 全局启用文本选择,uni-app H5 模式下默认不可选中 */
+text {
+  -webkit-user-select: text;
+  user-select: text;
 }
 
 /* ===== Global Keyframes ===== */

+ 24 - 3
admin-h5/src/pages/device/detail.vue

@@ -6,7 +6,10 @@
       <view class="device-main-info">
         <view class="device-name-section">
           <text class="device-name">{{ deviceDetail.deviceName || '未知设备' }}</text>
-          <text class="device-id">设备编号: {{ deviceDetail.shortId || '无' }}</text>
+          <view class="device-id-row">
+            <text class="device-id">设备编号: {{ deviceDetail.shortId || '无' }}</text>
+            <AppIcon v-if="deviceDetail.shortId" name="copy" :size="26" color="#C6171E" class="copy-icon" @click.stop="copyText(deviceDetail.shortId)" />
+          </view>
         </view>
         <view class="device-status" :style="getDeviceStatusStyle(deviceDetail.state)">
           <text class="status-dot"></text>
@@ -20,7 +23,10 @@
       <view class="info-list">
         <view class="info-item">
           <text class="item-label">产品Key</text>
-          <text class="item-value">{{ deviceDetail.productKey || '无' }}</text>
+          <view class="item-value-row">
+            <text class="item-value">{{ deviceDetail.productKey || '无' }}</text>
+            <AppIcon v-if="deviceDetail.productKey" name="copy" :size="26" color="#C6171E" class="copy-icon" @click.stop="copyText(deviceDetail.productKey)" />
+          </view>
         </view>
         <view class="info-item">
           <text class="item-label">工位编号</text>
@@ -109,7 +115,7 @@
 <script setup>
 import { ref, onMounted, computed } from 'vue'
 import { getDeviceDetail, stopDevice } from '../../api/device.js'
-import { formatTime, showToast, fmtDictName, getDictColor } from '../../utils/index.js'
+import { formatTime, showToast, fmtDictName, getDictColor, copyText } from '../../utils/index.js'
 import dictUtil, { loadDicts } from '../../utils/dict.js'
 
 const deviceId = ref('')
@@ -246,6 +252,11 @@ onMounted(async () => {
   margin-bottom: 10rpx;
 }
 
+.device-id-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
 .device-id {
   font-size: 24rpx;
   color: #999999;
@@ -314,6 +325,16 @@ onMounted(async () => {
   text-align: right;
 }
 
+.item-value-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.copy-icon {
+  flex-shrink: 0;
+}
+
 .item-value-danger {
   color: #F44336;
 }

+ 13 - 2
admin-h5/src/pages/device/list.vue

@@ -47,7 +47,10 @@
           <view class="device-info">
             <text class="device-name">{{ device.name }}</text>
             <view class="device-meta">
-              <text class="device-id">ID: {{ device.shortId }}</text>
+              <view class="device-id-row">
+                <text class="device-id">ID: {{ device.shortId }}</text>
+                <AppIcon v-if="device.shortId" name="copy" :size="24" color="#C6171E" class="copy-icon" @click.stop="copyText(device.shortId)" />
+              </view>
               <view class="device-status" :style="getDeviceStatusStyle(getDeviceStatusValue(device))">
                 <text class="status-dot"></text>
                 <text>{{ getDeviceStatusText(getDeviceStatusValue(device)) }}</text>
@@ -132,7 +135,7 @@ import { ref, onMounted, computed } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
 import { getDeviceList, stopDevice, getDeviceConfigList, batchModifyDeviceConfig } from '../../api/device.js'
 import { getStationList } from '../../api/station.js'
-import { showToast, storage, fmtDictName, getDictColor } from '../../utils/index.js'
+import { showToast, storage, fmtDictName, getDictColor, copyText } from '../../utils/index.js'
 import dictUtil, { loadDicts } from '../../utils/dict.js'
 
 const deviceList = ref([])
@@ -460,10 +463,18 @@ const isDeviceOnline = (status) => {
   flex-wrap: wrap;
 }
 
+.device-id-row {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+}
 .device-id {
   font-size: 24rpx;
   color: #999999;
 }
+.copy-icon {
+  flex-shrink: 0;
+}
 
 .device-status {
   display: flex;

+ 8 - 8
admin-h5/src/pages/finance/index.vue

@@ -86,23 +86,23 @@
               <text class="station-id">站点ID: {{ account.stationId }}</text>
             </view>
             <view class="account-balance">
-              <text class="balance-label">账户余额:</text>
-              <text class="balance-value">¥{{ formatAmount(account.balance || 0) }}</text>
+              <text class="balance-label">可提现金额:</text>
+              <text class="balance-value">¥{{ formatAmount(account.availableBalance || 0) }}</text>
             </view>
           </view>
           <view class="account-content">
             <view class="account-stats">
               <view class="stat-column">
-                <text class="stat-period">今日营收</text>
-                <text class="stat-amount">¥{{ formatAmount(account.todayRevenue || 0) }}</text>
+                <text class="stat-period">可提现金额</text>
+                <text class="stat-amount">¥{{ formatAmount(account.availableBalance || 0) }}</text>
               </view>
               <view class="stat-column">
-                <text class="stat-period">本月营收</text>
-                <text class="stat-amount">¥{{ formatAmount(account.monthRevenue || 0) }}</text>
+                <text class="stat-period">提现冻结</text>
+                <text class="stat-amount">¥{{ formatAmount(account.withdrawnFrozenAmount || 0) }}</text>
               </view>
               <view class="stat-column">
-                <text class="stat-period">累计营收</text>
-                <text class="stat-amount">¥{{ formatAmount(account.totalRevenue || 0) }}</text>
+                <text class="stat-period">已提现金额</text>
+                <text class="stat-amount">¥{{ formatAmount(account.withdrawnAmount || 0) }}</text>
               </view>
             </view>
             <button class="withdraw-btn" @click="navigateToWithdraw(account)">

+ 25 - 5
admin-h5/src/pages/finance/settlement.vue

@@ -52,10 +52,6 @@
               <text class="info-label">期初余额</text>
               <text class="info-value">{{ formatAmount(item.openingPendingBalance) }}</text>
             </view>
-            <view class="info-item">
-              <text class="info-label">结算金额</text>
-              <text class="info-value highlight">{{ formatAmount(item.settlementAmount) }}</text>
-            </view>
             <view class="info-item">
               <text class="info-label">总充值</text>
               <text class="info-value">{{ formatAmount(item.totalRecharge) }}</text>
@@ -64,6 +60,30 @@
               <text class="info-label">总退款</text>
               <text class="info-value">{{ formatAmount(item.totalRefund) }}</text>
             </view>
+            <view class="info-item">
+              <text class="info-label">跨店收入</text>
+              <text class="info-value">{{ formatAmount(item.totalCrossIncome) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">跨店支出</text>
+              <text class="info-value">{{ formatAmount(item.totalCrossExpend) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">平台费</text>
+              <text class="info-value">{{ formatAmount(item.platformFee) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">提现手续费</text>
+              <text class="info-value">{{ formatAmount(item.withdrawalFee) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">结算金额</text>
+              <text class="info-value highlight">{{ formatAmount(item.settlementAmount) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">期末余额</text>
+              <text class="info-value">{{ formatAmount(item.closingPendingBalance) }}</text>
+            </view>
           </view>
           <view class="item-time">
             <text class="time-text">创建: {{ formatTime(item.createTime) }}</text>
@@ -410,7 +430,7 @@ onMounted(async () => {
 }
 .info-grid {
   display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  grid-template-columns: repeat(3, 1fr);
   gap: 12rpx;
 }
 .info-item {

+ 13 - 2
admin-h5/src/pages/finance/split-record.vue

@@ -54,7 +54,10 @@
             </view>
             <view class="info-row">
               <text class="info-label">分账交易流水号</text>
-              <text class="info-value trade-no">{{ item.tradeNo || '-' }}</text>
+              <view class="info-value-row">
+                <text class="info-value trade-no">{{ item.tradeNo || '-' }}</text>
+                <AppIcon v-if="item.tradeNo" name="copy" :size="26" color="#C6171E" class="copy-icon" @click.stop="copyText(item.tradeNo)" />
+              </view>
             </view>
             <view class="info-row">
               <text class="info-label">创建时间</text>
@@ -96,7 +99,7 @@
 import { ref, onMounted } from 'vue'
 import { getSplitRecords } from '../../api/finance.js'
 import { getStationList } from '../../api/station.js'
-import { formatTime, formatAmount, fmtDictName, getDictColor, showToast } from '../../utils/index.js'
+import { formatTime, formatAmount, fmtDictName, getDictColor, showToast, copyText } from '../../utils/index.js'
 import { loadDicts } from '../../utils/dict.js'
 
 const list = ref([])
@@ -329,6 +332,11 @@ onMounted(async () => {
   font-size: 26rpx;
   color: #999999;
 }
+.info-value-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
 .info-value {
   font-size: 26rpx;
   color: #1A1A1A;
@@ -339,6 +347,9 @@ onMounted(async () => {
   font-size: 24rpx;
   word-break: break-all;
 }
+.copy-icon {
+  flex-shrink: 0;
+}
 
 .load-more {
   display: flex;

+ 17 - 1
admin-h5/src/pages/index/index.vue

@@ -72,16 +72,28 @@
         </view>
       </picker>
 
-      <!-- 主指标:订单 + 消费 -->
+      <!-- 主指标:订单 + 洗车人数 -->
       <view class="metrics-primary">
         <view class="metric-card-lg">
           <text class="metric-lg-value">{{ todayOrders }}</text>
           <text class="metric-lg-label">今日订单</text>
         </view>
+        <view class="metric-card-lg">
+          <text class="metric-lg-value">{{ todayWashUsers }}</text>
+          <text class="metric-lg-label">洗车人数</text>
+        </view>
+      </view>
+
+      <!-- 主指标:消费总额 + 今日退款 -->
+      <view class="metrics-primary">
         <view class="metric-card-lg">
           <text class="metric-lg-value">¥{{ formatAmount(todayConsumption) }}</text>
           <text class="metric-lg-label">消费总额</text>
         </view>
+        <view class="metric-card-lg">
+          <text class="metric-lg-value">¥{{ formatAmount(todayRefund) }}</text>
+          <text class="metric-lg-label">今日退款</text>
+        </view>
       </view>
 
       <!-- 次指标:均价 + 时长 -->
@@ -180,7 +192,9 @@ const selectedStationIndex = ref(0)
 
 const todayRevenue = ref(0)
 const todayOrders = ref(0)
+const todayWashUsers = ref(0)
 const todayConsumption = ref(0)
+const todayRefund = ref(0)
 const avgOrderPrice = ref(0)
 const avgDuration = ref(0)
 const todayMembers = ref(0)
@@ -257,7 +271,9 @@ const loadData = async (stationId) => {
       const data = dashboardRes.data
       todayRevenue.value = data.todayIncome || 0
       todayOrders.value = data.todayWashOrders || 0
+      todayWashUsers.value = data.todayWashUsers || 0
       todayConsumption.value = data.todayConsumptionAmount || 0
+      todayRefund.value = data.todayRefundAmount || 0
       avgOrderPrice.value = data.avgOrderPrice || 0
       avgDuration.value = Math.round((data.avgOrderDuration || 0) / 60 * 10) / 10
       todayMembers.value = data.todayRegisteredMembers || 0

+ 2 - 2
admin-h5/src/pages/login/login.vue

@@ -139,11 +139,11 @@ const handleLogin = async () => {
         })
       }, 800)
     } else {
-      showToast(res.msg || '登录失败')
+      showToast(res.msg || res.message || '登录失败')
     }
   } catch (error) {
     console.error('登录失败:', error)
-    showToast(error.msg || '网络错误,请稍后重试')
+    showToast(error.msg || error.message || '网络错误,请稍后重试')
   } finally {
     loading.value = false
   }

+ 31 - 4
admin-h5/src/pages/order/detail.vue

@@ -6,7 +6,10 @@
     <view class="status-card">
       <view class="order-number">
         <text class="number-label">订单号</text>
-        <text class="number-value">{{ orderDetail.orderId }}</text>
+        <view class="number-value-row">
+          <text class="number-value">{{ orderDetail.orderId }}</text>
+          <AppIcon name="copy" :size="28" color="#C6171E" class="copy-icon" @click.stop="copyText(orderDetail.orderId)" />
+        </view>
       </view>
       <view class="status-row">
         <text
@@ -29,11 +32,17 @@
         </view>
         <view class="info-item">
           <text class="item-label">用户手机号</text>
-          <text class="item-value">{{ orderDetail.mobilePhone || '匿名用户' }}</text>
+          <view class="item-value-row">
+            <text class="item-value">{{ orderDetail.mobilePhone || '匿名用户' }}</text>
+            <AppIcon v-if="orderDetail.mobilePhone" name="copy" :size="28" color="#C6171E" class="copy-icon" @click.stop="copyText(orderDetail.mobilePhone)" />
+          </view>
         </view>
         <view class="info-item">
           <text class="item-label">设备编号</text>
-          <text class="item-value">{{ orderDetail.shortId || '未知设备' }}</text>
+          <view class="item-value-row">
+            <text class="item-value">{{ orderDetail.shortId || '未知设备' }}</text>
+            <AppIcon v-if="orderDetail.shortId" name="copy" :size="28" color="#C6171E" class="copy-icon" @click.stop="copyText(orderDetail.shortId)" />
+          </view>
         </view>
         <view class="info-item">
           <text class="item-label">消费站点</text>
@@ -132,7 +141,7 @@
 <script setup>
 import { ref, onMounted, computed } from 'vue'
 import { getOrderDetail, handleRefund as refundApi } from '../../api/order.js'
-import { formatTime, showToast, formatAmount, fmtDictName, getDictColor } from '../../utils/index.js'
+import { formatTime, showToast, formatAmount, fmtDictName, getDictColor, copyText } from '../../utils/index.js'
 import dictUtil, { loadDicts } from '../../utils/dict.js'
 
 const orderId = ref('')
@@ -392,6 +401,24 @@ onMounted(async () => {
   text-align: right;
 }
 
+.item-value-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.number-value-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  flex: 1;
+  justify-content: flex-end;
+}
+
+.copy-icon {
+  flex-shrink: 0;
+}
+
 /* 金额列表 */
 .amount-list {
   padding: 20rpx 30rpx 24rpx;

+ 16 - 9
admin-h5/src/pages/order/list.vue

@@ -68,11 +68,17 @@
         <view class="order-core-info">
           <view class="info-item">
             <text class="info-label">用户手机号</text>
-            <text class="info-value">{{ order.mobilePhone || '匿名用户' }}</text>
+            <view class="info-value-row">
+              <text class="info-value">{{ order.mobilePhone || '匿名用户' }}</text>
+              <AppIcon v-if="order.mobilePhone" name="copy" :size="28" color="#C6171E" class="copy-icon" @click.stop="copyText(order.mobilePhone)" />
+            </view>
           </view>
           <view class="info-item">
             <text class="info-label">设备编号</text>
-            <text class="info-value">{{ order.shortId || '未知设备' }}</text>
+            <view class="info-value-row">
+              <text class="info-value">{{ order.shortId || '未知设备' }}</text>
+              <AppIcon v-if="order.shortId" name="copy" :size="28" color="#C6171E" class="copy-icon" @click.stop="copyText(order.shortId)" />
+            </view>
           </view>
         </view>
         
@@ -154,7 +160,7 @@ import CustomTabBar from '../../components/CustomTabBar.vue'
 import { ref, onMounted, reactive } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
 import { getOrderList, handleRefund as refundApi } from '../../api/order.js'
-import { formatTime, showToast, formatAmount, fmtDictName, getDictColor } from '../../utils/index.js'
+import { formatTime, showToast, formatAmount, fmtDictName, getDictColor, copyText } from '../../utils/index.js'
 import dictUtil, { loadDicts } from '../../utils/dict.js'
 
 const orderList = ref([])
@@ -278,12 +284,7 @@ const handleSearch = () => {
 
 
 const copyOrderId = (orderId) => {
-  uni.setClipboardData({
-    data: String(orderId),
-    success: () => {
-      showToast('订单号已复制', 'success')
-    }
-  })
+  copyText(orderId)
 }
 
 const viewOrderDetail = (orderId) => {
@@ -550,6 +551,12 @@ const onPullDownRefresh = () => {
   word-break: break-all;
 }
 
+.info-value-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+
 /* 金额信息 */
 .order-amount-info {
   display: flex;

+ 102 - 75
admin-h5/src/pages/statistics/index.vue

@@ -38,11 +38,11 @@
       <button class="search-btn" @click="handleSearch">查询</button>
     </view>
 
-    <!-- 汇总卡片 -->
-    <view class="summary-range" v-if="list.length > 0">
+    <!-- 汇总卡片(仅日统计) -->
+    <view class="summary-range" v-if="activeTab === 0 && list.length > 0">
       <text class="range-text">统计范围:{{ summaryDateRange }}</text>
     </view>
-    <view class="summary-section" v-if="list.length > 0">
+    <view class="summary-section" v-if="activeTab === 0 && list.length > 0">
       <view class="summary-card">
         <text class="summary-label">总收入</text>
         <text class="summary-value highlight">¥{{ formatAmount(summary.totalIncome) }}</text>
@@ -77,11 +77,9 @@
         :key="index"
         @click="viewDetail(item)">
         <view class="item-header">
-          <view class="item-left">
-            <text class="item-period">{{ activeTab === 0 ? item.statDate : item.statMonth }}</text>
-            <text class="item-station">{{ item.stationName || '-' }}</text>
-          </view>
-          <AppIcon name="chevron-right" :size="24" color="#B0B0B0" />
+          <text class="item-period">{{ activeTab === 0 ? item.statDate : item.statMonth }}</text>
+          <text class="item-station">{{ item.stationName || '-' }}</text>
+          <AppIcon name="chevron-right" :size="24" color="#B0B0B0" class="item-header-arrow" />
         </view>
         <view class="item-content">
           <view class="info-grid">
@@ -101,6 +99,14 @@
               <text class="info-label">订单数</text>
               <text class="info-value">{{ item.ordersCount || 0 }}</text>
             </view>
+            <view class="info-item">
+              <text class="info-label">注册用户</text>
+              <text class="info-value">{{ item.registrations || 0 }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">活跃用户</text>
+              <text class="info-value">{{ item.activeUserCount || 0 }}</text>
+            </view>
           </view>
         </view>
       </view>
@@ -137,61 +143,63 @@
           </view>
         </view>
         <view class="detail-content" v-if="detailItem">
-          <view class="detail-row">
-            <text class="d-label">{{ activeTab === 0 ? '日期' : '月份' }}</text>
-            <text class="d-value">{{ activeTab === 0 ? detailItem.statDate : detailItem.statMonth }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">站点名称</text>
-            <text class="d-value">{{ detailItem.stationName || '-' }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">总收入</text>
-            <text class="d-value amount">¥{{ formatAmount(detailItem.totalIncome) }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">退款金额</text>
-            <text class="d-value">¥{{ formatAmount(detailItem.totalRefund) }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">跨店收入</text>
-            <text class="d-value">¥{{ formatAmount(detailItem.crossIncome) }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">跨店支出</text>
-            <text class="d-value">¥{{ formatAmount(detailItem.crossExpend) }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">消费金额</text>
-            <text class="d-value">¥{{ formatAmount(detailItem.consumption) }}</text>
-          </view>
-          <view class="detail-row" v-if="activeTab === 1 && detailItem.platformFee !== undefined">
-            <text class="d-label">平台服务费</text>
-            <text class="d-value">¥{{ formatAmount(detailItem.platformFee) }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">充值笔数</text>
-            <text class="d-value">{{ detailItem.rechargeCount || 0 }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">退款笔数</text>
-            <text class="d-value">{{ detailItem.refundCount || 0 }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">注册人数</text>
-            <text class="d-value">{{ detailItem.registrations || 0 }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">活跃用户数</text>
-            <text class="d-value">{{ detailItem.activeUserCount || 0 }}</text>
-          </view>
-          <view class="detail-row">
-            <text class="d-label">订单数量</text>
-            <text class="d-value">{{ detailItem.ordersCount || 0 }}</text>
-          </view>
-          <view class="detail-row" v-if="detailItem.createTime">
-            <text class="d-label">创建时间</text>
-            <text class="d-value">{{ formatTime(detailItem.createTime) }}</text>
+          <view class="detail-grid">
+            <view class="detail-cell">
+              <text class="d-label">{{ activeTab === 0 ? '日期' : '月份' }}</text>
+              <text class="d-value">{{ activeTab === 0 ? detailItem.statDate : detailItem.statMonth }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">站点名称</text>
+              <text class="d-value">{{ detailItem.stationName || '-' }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">总收入</text>
+              <text class="d-value amount">¥{{ formatAmount(detailItem.totalIncome) }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">退款金额</text>
+              <text class="d-value">¥{{ formatAmount(detailItem.totalRefund) }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">跨店收入</text>
+              <text class="d-value">¥{{ formatAmount(detailItem.crossIncome) }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">跨店支出</text>
+              <text class="d-value">¥{{ formatAmount(detailItem.crossExpend) }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">消费金额</text>
+              <text class="d-value">¥{{ formatAmount(detailItem.consumption) }}</text>
+            </view>
+            <view class="detail-cell" v-if="activeTab === 1 && detailItem.platformFee !== undefined">
+              <text class="d-label">平台服务费</text>
+              <text class="d-value">¥{{ formatAmount(detailItem.platformFee) }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">充值笔数</text>
+              <text class="d-value">{{ detailItem.rechargeCount || 0 }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">退款笔数</text>
+              <text class="d-value">{{ detailItem.refundCount || 0 }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">注册人数</text>
+              <text class="d-value">{{ detailItem.registrations || 0 }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">活跃用户数</text>
+              <text class="d-value">{{ detailItem.activeUserCount || 0 }}</text>
+            </view>
+            <view class="detail-cell">
+              <text class="d-label">订单数量</text>
+              <text class="d-value">{{ detailItem.ordersCount || 0 }}</text>
+            </view>
+            <view class="detail-cell" v-if="detailItem.createTime">
+              <text class="d-label">创建时间</text>
+              <text class="d-value">{{ formatTime(detailItem.createTime) }}</text>
+            </view>
           </view>
         </view>
       </view>
@@ -459,8 +467,10 @@ onMounted(async () => {
   align-items: center;
   background-color: #F5F5F5;
   border-radius: 16rpx;
-  padding: 14rpx 20rpx;
+  padding: 0 20rpx;
   font-size: 26rpx;
+  height: 56rpx;
+  box-sizing: border-box;
 }
 .picker-value .placeholder {
   color: #B0B0B0;
@@ -471,9 +481,10 @@ onMounted(async () => {
 .filter-item input {
   background-color: #F5F5F5;
   border-radius: 16rpx;
-  padding: 14rpx 20rpx;
+  padding: 0 20rpx;
   font-size: 26rpx;
   height: 56rpx;
+  box-sizing: border-box;
 }
 .search-btn {
   width: 100%;
@@ -487,6 +498,7 @@ onMounted(async () => {
 
 .summary-range {
   padding: 8rpx 20rpx 12rpx;
+  text-align: center;
 }
 .range-text {
   font-size: 22rpx;
@@ -504,6 +516,7 @@ onMounted(async () => {
   padding: 24rpx;
   border-radius: 16rpx;
   box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  text-align: center;
 }
 .summary-label {
   font-size: 24rpx;
@@ -537,31 +550,39 @@ onMounted(async () => {
 }
 .item-header {
   display: flex;
-  justify-content: space-between;
+  flex-direction: column;
   align-items: center;
   padding-bottom: 16rpx;
   border-bottom: 1rpx solid #F0F0F0;
   margin-bottom: 16rpx;
+  position: relative;
 }
 .item-period {
   font-size: 28rpx;
   font-weight: 600;
   color: #1A1A1A;
-  display: block;
+  text-align: center;
 }
 .item-station {
   font-size: 24rpx;
   color: #999999;
   margin-top: 4rpx;
-  display: block;
+  text-align: center;
+}
+.item-header-arrow {
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translateY(-50%);
 }
 .info-grid {
   display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  grid-template-columns: repeat(3, 1fr);
   gap: 12rpx;
 }
 .info-item {
   padding: 8rpx 0;
+  text-align: center;
 }
 .info-label {
   font-size: 24rpx;
@@ -658,21 +679,27 @@ onMounted(async () => {
 .detail-close {
   padding: 10rpx;
 }
-.detail-row {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 16rpx 0;
+.detail-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 16rpx 12rpx;
+}
+.detail-cell {
+  text-align: center;
+  padding: 12rpx 0;
   border-bottom: 1rpx solid #F5F5F5;
 }
 .d-label {
-  font-size: 26rpx;
+  font-size: 24rpx;
   color: #999999;
+  display: block;
+  margin-bottom: 4rpx;
 }
 .d-value {
   font-size: 26rpx;
   color: #1A1A1A;
   font-weight: 500;
+  display: block;
 }
 .d-value.amount {
   color: #C6171E;

+ 23 - 6
admin-h5/src/pages/user/list.vue

@@ -15,8 +15,14 @@
       <view class="user-item" v-for="(item, index) in list" :key="index">
         <view class="item-header">
           <view class="item-left">
-            <text class="item-phone">{{ item.mobilePhone || '-' }}</text>
-            <text class="item-id">UID: {{ item.userId || '-' }}</text>
+            <view class="item-phone-row">
+              <text class="item-phone">{{ item.mobilePhone || '-' }}</text>
+              <AppIcon v-if="item.mobilePhone" name="copy" :size="26" color="#C6171E" class="copy-icon" @click.stop="copyText(item.mobilePhone)" />
+            </view>
+            <view class="item-id-row">
+              <text class="item-id">UID: {{ item.userId || '-' }}</text>
+              <AppIcon v-if="item.userId" name="copy" :size="26" color="#C6171E" class="copy-icon" @click.stop="copyText(item.userId)" />
+            </view>
           </view>
           <view class="item-header-right">
             <text class="status-tag" :style="getStatusStyle(item.status)">{{ fmtDictName('User.status', item.status) }}</text>
@@ -124,7 +130,7 @@ import { ref, onMounted } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
 import { getAppUserList } from '../../api/user.js'
 import { applyRefund } from '../../api/finance.js'
-import { formatTime, showToast, formatAmount, fmtDictName, getDictColor } from '../../utils/index.js'
+import { formatTime, showToast, formatAmount, fmtDictName, getDictColor, copyText } from '../../utils/index.js'
 import { loadDicts } from '../../utils/dict.js'
 
 const list = ref([])
@@ -328,17 +334,28 @@ onMounted(async () => {
   border-bottom: 1rpx solid #F0F0F0;
   margin-bottom: 16rpx;
 }
+.item-phone-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
 .item-phone {
   font-size: 30rpx;
   font-weight: 600;
   color: #1A1A1A;
-  display: block;
+}
+.item-id-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 4rpx;
 }
 .item-id {
   font-size: 22rpx;
   color: #999999;
-  margin-top: 4rpx;
-  display: block;
+}
+.copy-icon {
+  flex-shrink: 0;
 }
 .item-header-right {
   display: flex;

+ 20 - 0
admin-h5/src/utils/index.js

@@ -5,7 +5,9 @@ export { storage } from './storage.js'
 export { fmtDictName, getDictColor } from './dict.js'
 
 export const formatTime = (time, format = 'YYYY-MM-DD HH:mm:ss') => {
+  if (!time) return '-'
   const date = new Date(time)
+  if (isNaN(date.getTime())) return '-'
   const year = date.getFullYear()
   const month = String(date.getMonth() + 1).padStart(2, '0')
   const day = String(date.getDate()).padStart(2, '0')
@@ -28,3 +30,21 @@ export const formatAmount = (amount, isCent = true) => {
   const yuan = isCent ? numAmount / 100 : numAmount
   return yuan.toFixed(2)
 }
+
+/**
+ * 复制文本到剪贴板
+ * @param {string} text - 要复制的文本
+ */
+export const copyText = async (text) => {
+  if (!text && text !== 0) {
+    showToast('内容为空,无法复制')
+    return
+  }
+  try {
+    await uni.setClipboardData({ data: String(text) })
+    showToast('已复制', 'success')
+  } catch (e) {
+    console.error('复制失败:', e)
+    showToast('复制失败')
+  }
+}

+ 4 - 1
admin-h5/src/utils/request.js

@@ -48,7 +48,10 @@ export const request = (config) => {
           if (data.code === 200) {
             resolve(data)
           } else {
-            showToast(data.msg || '请求失败')
+            // 登录接口的错误由 login.vue 自行处理,不在此处提示
+            if (url !== '/admin-user/login') {
+              showToast(data.msg || data.message || '请求失败')
+            }
             reject(data)
           }
         } else if (statusCode === 401) {

+ 5 - 0
admin-web-new/src/api/finance.ts

@@ -30,6 +30,11 @@ export const triggerSettlement = () => {
   return http.request<any>("post", "/finance/triggerSettlement");
 };
 
+/** 确认结算 */
+export const confirmSettlement = (id: number) => {
+  return http.request<any>("post", "/finance/confirmSettlement", { data: { id } });
+};
+
 /** 用户资金流水列表 */
 export const getWalletDetailList = (data?: object) => {
   return http.request<any>("get", "/finance/walletDetails", { params: data });

+ 28 - 0
admin-web-new/src/style/login.css

@@ -238,6 +238,34 @@ html.dark .login-header p {
   color: oklch(70% 0.005 30);
 }
 
+/* ========== ICP 备案号 ========== */
+.login-icp-separator {
+  color: rgb(255 255 255 / 20%);
+}
+
+.login-icp-footer-mobile {
+  display: block;
+  text-align: center;
+  padding-top: 24px;
+}
+
+.login-icp-footer-mobile a {
+  font-size: 13px;
+  color: #fff;
+  text-decoration: none;
+}
+
+.login-icp-footer-mobile a:hover {
+  color: #ccc;
+  text-decoration: underline;
+}
+
+@media (min-width: 1024px) {
+  .login-icp-footer-mobile {
+    display: none;
+  }
+}
+
 /* ========== 响应式 ========== */
 @media (max-width: 1023px) {
   .login-brand-panel {

+ 59 - 6
admin-web-new/src/views/admin/finance/settlement.vue

@@ -1,9 +1,9 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getSettlementList, triggerSettlement } from "@/api/finance";
+import { getSettlementList, triggerSettlement, confirmSettlement } from "@/api/finance";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
-import { ElMessage } from "element-plus";
+import { ElMessage, ElMessageBox } from "element-plus";
 import { getDictOptions, formatDict, getDictColor } from "@/utils/dict";
 
 defineOptions({
@@ -29,7 +29,7 @@ const state = reactive({
     data: [] as Array<any>,
     loading: false,
     columns: [
-      { label: "站点名称", prop: "stationName", width: 180 },
+      { label: "站点", prop: "stationId", width: 180 },
       { label: "结算周期", prop: "settlementPeriod", width: 120 },
       { label: "期初余额(元)", prop: "openingPendingBalance", width: 150 },
       { label: "总充值(元)", prop: "totalRecharge", width: 140 },
@@ -38,11 +38,13 @@ const state = reactive({
       { label: "跨店支出(元)", prop: "totalCrossExpend", width: 140 },
       { label: "平台费基数(元)", prop: "platformFeeBase", width: 150 },
       { label: "平台费(元)", prop: "platformFee", width: 130 },
+      { label: "提现手续费(元)", prop: "withdrawalFee", width: 150 },
       { label: "结算金额(元)", prop: "settlementAmount", width: 150 },
       { label: "期末余额(元)", prop: "closingPendingBalance", width: 150 },
       { label: "状态", prop: "status", width: 100 },
       { label: "备注", prop: "remark", width: 180 },
-      { label: "创建时间", prop: "createTime", width: 180 }
+      { label: "创建时间", prop: "createTime", width: 180 },
+      { label: "操作", prop: "action", width: 100, fixed: "right" }
     ]
   },
   stationOptions: [] as Array<{ stationId: string; stationName: string }>,
@@ -103,6 +105,23 @@ const handleTriggerSettlement = () => {
     });
 };
 
+const handleConfirmSettlement = (row: any) => {
+  ElMessageBox.confirm(
+    `确认将站点「${row.stationName}」${row.settlementPeriod} 的结算金额 ¥${(row.settlementAmount / 100).toFixed(2)} 转入可提现余额?`,
+    "确认结算",
+    { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
+  ).then(() => {
+    confirmSettlement(row.id)
+      .then(() => {
+        ElMessage.success("结算已确认,金额已转入站点可提现余额");
+        loadData(true);
+      })
+      .catch(() => {
+        ElMessage.error("确认结算失败");
+      });
+  }).catch(() => {});
+};
+
 const handleSizeChange = (size: number) => {
   state.pageQuery.pageSize = size;
   loadData(true);
@@ -162,12 +181,23 @@ const formatMoney = (value: number) => {
         </template>
         <el-table-column v-for="col in state.tableData.columns" :key="col.prop" :prop="col.prop" :label="col.label" :width="col.width" show-overflow-tooltip>
           <template #default="{ row }">
-            <template v-if="col.prop === 'status'">
+            <template v-if="col.prop === 'stationId'">
+              <div class="station-name-cell">
+                <span class="station-id">{{ row.stationId }}</span>
+                <span class="station-divider"></span>
+                <span class="station-name">{{ row.stationName }}</span>
+              </div>
+            </template>
+            <template v-else-if="col.prop === 'status'">
               <el-tag :type="getDictColor('Settlement.status', row.status)" size="small">
                 {{ formatDict('Settlement.status', row.status) }}
               </el-tag>
             </template>
-            <template v-else-if="['openingPendingBalance', 'totalRecharge', 'totalRefund', 'totalCrossIncome', 'totalCrossExpend', 'platformFeeBase', 'platformFee', 'settlementAmount', 'closingPendingBalance'].includes(col.prop)">
+            <template v-else-if="col.prop === 'action'">
+              <el-button v-if="row.status === 0" type="primary" size="small" @click="handleConfirmSettlement(row)">确认结算</el-button>
+              <span v-else style="color: #909399; font-size: 12px;">-</span>
+            </template>
+            <template v-else-if="['openingPendingBalance', 'totalRecharge', 'totalRefund', 'totalCrossIncome', 'totalCrossExpend', 'platformFeeBase', 'platformFee', 'withdrawalFee', 'settlementAmount', 'closingPendingBalance'].includes(col.prop)">
               {{ formatMoney(row[col.prop]) }}
             </template>
             <template v-else>
@@ -206,6 +236,29 @@ const formatMoney = (value: number) => {
   margin-bottom: 16px;
 }
 
+.station-name-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+
+  .station-name {
+    font-weight: 500;
+  }
+
+  .station-divider {
+    display: block;
+    width: 40px;
+    height: 1px;
+    background: var(--el-border-color-light);
+  }
+
+  .station-id {
+    color: var(--el-text-color-secondary);
+    font-size: 12px;
+  }
+}
+
 .pagination-container {
   display: flex;
   justify-content: flex-end;

+ 5 - 0
admin-web-new/src/views/login/index.vue

@@ -109,6 +109,8 @@ useEventListener(document, "keydown", ({ code }) => {
       </div>
       <div class="login-brand-footer">
         <span>&copy; {{ new Date().getFullYear() }} {{ title }}</span>
+        <span class="login-icp-separator">|</span>
+        <a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">粤ICP备2025408522号-2</a>
       </div>
       <div class="login-brand-grid" />
       <div class="login-brand-orb login-brand-orb--1" />
@@ -147,6 +149,9 @@ useEventListener(document, "keydown", ({ code }) => {
             </el-form-item>
           </el-form>
         </div>
+        <div class="login-icp-footer-mobile">
+          <a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">粤ICP备2025408522号-2</a>
+        </div>
       </div>
     </div>
   </div>

+ 52 - 3
admin-web/src/views/admin/finance/settlement.vue

@@ -19,6 +19,29 @@
   margin-bottom: 20px;
 }
 
+.station-name-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+
+  .station-name {
+    font-weight: 500;
+  }
+
+  .station-divider {
+    display: block;
+    width: 40px;
+    height: 1px;
+    background: var(--el-border-color-light);
+  }
+
+  .station-id {
+    color: var(--el-text-color-secondary);
+    font-size: 12px;
+  }
+}
+
 .page-pager {
   background-color: var(--el-color-white);
   height: 24px;
@@ -91,12 +114,23 @@
             :show-overflow-tooltip="!field.fixed&&field.width>150">
 
           <template #default="{row}">
-            <template v-if="field.prop==='status'">
+            <template v-if="field.prop==='stationId'">
+              <div class="station-name-cell">
+                <span class="station-id">{{ row.stationId }}</span>
+                <span class="station-divider"></span>
+                <span class="station-name">{{ row.stationName }}</span>
+              </div>
+            </template>
+            <template v-else-if="field.prop==='action'">
+              <el-button v-if="row.status===0" type="primary" size="small" @click="handleConfirmSettlement(row)">确认结算</el-button>
+              <span v-else style="color: #909399; font-size: 12px;">-</span>
+            </template>
+            <template v-else-if="field.prop==='status'">
               <el-tag :type="getStatusType(row.status)" size="small">
                 {{ getStatusLabel(row.status) }}
               </el-tag>
             </template>
-            <template v-else-if="['openingPendingBalance','totalRecharge','totalRefund','totalCrossIncome','totalCrossExpend','platformFeeBase','platformFee','settlementAmount','closingPendingBalance'].includes(field.prop)">
+            <template v-else-if="['openingPendingBalance','totalRecharge','totalRefund','totalCrossIncome','totalCrossExpend','platformFeeBase','platformFee','withdrawalFee','settlementAmount','closingPendingBalance'].includes(field.prop)">
               {{ u.fmt.fmtMoney(row[field.prop]) }}
             </template>
             <template v-else-if="['createTime','updateTime'].includes(field.prop)">
@@ -142,7 +176,7 @@ const state = reactive({
     loading: false
   },
   columns: [
-    {label: '站点名称', width: 180, prop: 'stationName', resizable: true},
+    {label: '站点', width: 180, prop: 'stationId', resizable: true},
     {label: '结算周期', width: 120, prop: 'settlementPeriod', resizable: true},
     {label: '期初余额(元)', width: 150, prop: 'openingPendingBalance', resizable: true},
     {label: '总充值(元)', width: 140, prop: 'totalRecharge', resizable: true},
@@ -151,11 +185,13 @@ const state = reactive({
     {label: '跨店支出(元)', width: 140, prop: 'totalCrossExpend', resizable: true},
     {label: '平台费基数(元)', width: 150, prop: 'platformFeeBase', resizable: true},
     {label: '平台费(元)', width: 130, prop: 'platformFee', resizable: true},
+    {label: '提现手续费(元)', width: 150, prop: 'withdrawalFee', resizable: true},
     {label: '结算金额(元)', width: 150, prop: 'settlementAmount', resizable: true},
     {label: '期末余额(元)', width: 150, prop: 'closingPendingBalance', resizable: true},
     {label: '状态', width: 100, prop: 'status', resizable: true},
     {label: '备注', width: 180, prop: 'remark', resizable: true},
     {label: '创建时间', width: 180, prop: 'createTime', resizable: true},
+    {label: '操作', width: 100, prop: 'action', fixed: 'right', resizable: true},
   ],
 })
 
@@ -198,6 +234,19 @@ const handleTriggerSettlement = () => {
   })
 };
 
+const handleConfirmSettlement = (row: any) => {
+  Msg.confirm(`确认将站点「${row.stationName}」${row.settlementPeriod} 的结算金额 ¥${(row.settlementAmount / 100).toFixed(2)} 转入可提现余额?`, '确认结算').then(() => {
+    Msg.showLoading(`确认中...`);
+    $body(`finance/confirmSettlement`, { id: row.id }).then(() => {
+      Msg.message("结算已确认,金额已转入站点可提现余额");
+      Msg.hideLoading();
+      loadData(true);
+    }).catch(() => {
+      Msg.hideLoading();
+    })
+  }).catch(() => {});
+};
+
 const getStatusLabel = (status: number) => u.fmt.fmtDict(status, 'Settlement.status');
 const getStatusType = (status: number): any => u.fmt.fmtDictColor(status, 'Settlement.status');
 </script>

+ 5 - 5
admin-web/src/views/admin/index.vue

@@ -45,7 +45,7 @@
 
 
             <div class="trend-summary">
-              总收金额:
+              总订单实收金额:
               <el-tag type="success">{{ u.fmt.fmtMoney(state.homeOneExtra.totalIncome||0) }}元</el-tag>
               总订单数量:
               <el-tag type="danger">{{ state.homeOneExtra.totalWashOrders||0 }}笔</el-tag>
@@ -228,13 +228,13 @@ const initLineChart = (dataList: Array<any>) => {
       trigger: 'axis',
       formatter: (params: any) => {
         const bar = params.find((p: any) => p.seriesName === '洗车量');
-        const line = params.find((p: any) => p.seriesName === '金额');
+        const line = params.find((p: any) => p.seriesName === '订单实收金额');
         const barVal = bar ? `${bar.value} 次` : '';
         const lineVal = line ? `¥${line.value}` : '';
         return `${params[0].axisValue}<br/>${barVal}<br/>${lineVal}`;
       }
     },
-    legend: {data: ['洗车量','金额',], right: 20},
+    legend: {data: ['洗车量','订单实收金额',], right: 20},
     xAxis: {
       data: xAxis,
       // data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
@@ -271,13 +271,13 @@ const initLineChart = (dataList: Array<any>) => {
         },
       },
       {
-        name: '金额',
+        name: '订单实收金额',
         type: 'line',
         symbolSize: 6,
         symbol: 'circle',
         smooth: true,
         yAxisIndex: 1,
-        data: dataList.map(k => (k.totalAmount / 100).toFixed(2)),
+        data: dataList.map(k => (k.amountReceived / 100).toFixed(2)),
         lineStyle: {color: '#3770ff'},
         itemStyle: {color: '#3770ff', borderColor: '#3770ff'},
         areaStyle: {

+ 22 - 1
admin-web/src/views/login/index.vue

@@ -1,5 +1,5 @@
 <template>
-	<div class="login-container flex" style="justify-content: space-between">
+	<div class="login-container flex" style="justify-content: space-between; position: relative;">
 		<div class="login-left">
 <!--			<div class="login-left-logo">-->
 <!--				<img :src="logoMini" />-->
@@ -39,6 +39,9 @@
 				</div>
 			</div>
 		</div>
+		<div class="login-icp-footer">
+			<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">粤ICP备2025408522号-2</a>
+		</div>
 	</div>
 </template>
 
@@ -255,4 +258,22 @@ onMounted(() => {
 		}
 	}
 }
+
+.login-icp-footer {
+	position: absolute;
+	bottom: 12px;
+	left: 0;
+	right: 0;
+	text-align: center;
+	z-index: 10;
+}
+.login-icp-footer a {
+	font-size: 13px;
+	color: #fff;
+	text-decoration: none;
+}
+.login-icp-footer a:hover {
+	color: #ccc;
+	text-decoration: underline;
+}
 </style>

+ 6 - 4
car-wash-admin/src/main/java/com/kym/admin/aspect/SysLogAspect.java

@@ -84,10 +84,12 @@ public class SysLogAspect {
         HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
         //设置IP地址
         systemLog.setIp(IPUtils.getIpAddr(request));
-        //用户名、公司id、后台用户id
-        systemLog.setUserId(StpUtil.getSession().getLong("userId"));
-        systemLog.setCompanyId(StpUtil.getSession().getLong("companyId"));
-        systemLog.setUsername(StpUtil.getSession().getString("mobilePhone"));
+        //用户名、公司id、后台用户id(登录失败时无会话,跳过)
+        if (StpUtil.isLogin()) {
+            systemLog.setUserId(StpUtil.getSession().getLong("userId"));
+            systemLog.setCompanyId(StpUtil.getSession().getLong("companyId"));
+            systemLog.setUsername(StpUtil.getSession().getString("mobilePhone"));
+        }
         systemLog.setExecuteTime(executeTime);
 
         //保存系统日志

+ 25 - 2
car-wash-admin/src/main/java/com/kym/admin/config/SaTokenConfigure.java

@@ -1,8 +1,13 @@
 package com.kym.admin.config;
 
+import cn.dev33.satoken.annotation.SaIgnore;
+import cn.dev33.satoken.context.SaHolder;
 import cn.dev33.satoken.interceptor.SaInterceptor;
 import cn.dev33.satoken.stp.StpUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.HandlerMethod;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
@@ -13,13 +18,31 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
  */
 @Configuration
 public class SaTokenConfigure implements WebMvcConfigurer {
+
+    private static final Logger log = LoggerFactory.getLogger(SaTokenConfigure.class);
+
     // 注册拦截器
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
         // 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
-        registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
+        registry.addInterceptor(new SaInterceptor(handler -> {
+                    // 1. 通过请求路径判断:login 接口不鉴权
+                    String path = SaHolder.getRequest().getRequestPath();
+                    if ("/admin-user/login".equals(path)) {
+                        log.debug("SaInterceptor: 跳过 login 接口鉴权");
+                        return;
+                    }
+                    // 2. 检查 @SaIgnore 注解,有则跳过鉴权
+                    if (handler instanceof HandlerMethod hm) {
+                        if (hm.hasMethodAnnotation(SaIgnore.class)
+                                || hm.getBeanType().isAnnotationPresent(SaIgnore.class)) {
+                            log.debug("SaInterceptor: @SaIgnore 跳过鉴权 - {}", hm.getMethod().getName());
+                            return;
+                        }
+                    }
+                    StpUtil.checkLogin();
+                }))
                 .addPathPatterns("/**")
-                // login接口不鉴权
                 .excludePathPatterns(
                         "/wx/*",
                         "/finance/export/*",

+ 30 - 0
car-wash-admin/src/main/java/com/kym/admin/config/SchedulingConfig.java

@@ -0,0 +1,30 @@
+package com.kym.admin.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * 调度线程池配置
+ * ScheduledExecutorService 暂不支持虚拟线程,所以用 2 线程调度器负责触发,
+ * 实际工作在 @Async("AsyncExecutor") 的虚拟线程上执行,不阻塞调度线程。
+ *
+ * @author skyline
+ */
+@Configuration
+@Slf4j
+public class SchedulingConfig {
+
+    @Bean
+    public TaskScheduler taskScheduler() {
+        log.info("初始化调度线程池(虚拟线程模式)");
+        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+        scheduler.setPoolSize(2);
+        scheduler.setThreadGroupName("admin-schedule");
+        scheduler.setThreadNamePrefix("sched-");
+        scheduler.initialize();
+        return scheduler;
+    }
+}

+ 2 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/AdminUserController.java

@@ -1,6 +1,7 @@
 package com.kym.admin.controller;
 
 import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaIgnore;
 import cn.dev33.satoken.stp.StpUtil;
 import com.alibaba.fastjson2.JSONObject;
 import com.kym.common.R;
@@ -42,6 +43,7 @@ public class AdminUserController extends IController {
     }
 
     @SysLog("登录")
+    @SaIgnore
     @PostMapping("/login")
     R<?> adminLogin(@RequestBody JSONObject json) {
         return adminUserService.login(json.getString("mobilePhone"), json.getString("password"));

+ 19 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java

@@ -8,6 +8,7 @@ import com.kym.entity.queryParams.WalletDetailQueryParam;
 import com.kym.service.*;
 import com.kym.service.wechat.WxPayService;
 import jakarta.validation.Valid;
+import java.util.Map;
 import org.springframework.web.bind.annotation.*;
 
 /**
@@ -74,6 +75,7 @@ public class FinanceController {
      * @param params
      * @return
      */
+    @SaCheckPermission("withdrawnRecord.modify")
     @PostMapping("/applyWithdrawn")
     public R<?> applyWithdrawn(@RequestBody WithdrawnQueryParam params) {
         stationAccountService.applyWithdrawn(params);
@@ -97,6 +99,7 @@ public class FinanceController {
      * @param params
      * @return
      */
+    @SaCheckPermission("withdrawnRecord.modify")
     @PostMapping("/reviewWithdrawn")
     public R<?> reviewWithdrawn(@RequestBody WithdrawnQueryParam params) {
         withdrawnRecordService.reviewWithdrawn(params);
@@ -109,6 +112,7 @@ public class FinanceController {
      * @param params
      * @return
      */
+    @SaCheckPermission("withdrawnRecord.modify")
     @PostMapping("/confirmWithdrawnPayment")
     public R<?> confirmWithdrawnPayment(@RequestBody WithdrawnQueryParam params) {
         withdrawnRecordService.confirmWithdrawnPayment(params);
@@ -122,6 +126,7 @@ public class FinanceController {
      * @param refundLogId
      * @return
      */
+    @SaCheckPermission("refundLog.modify")
     @SysLog("处理用户微信退款")
     @GetMapping("/customWxRefund/{refundLogId}")
     R<?> customWxRefund(@PathVariable("refundLogId") long refundLogId) {
@@ -132,6 +137,7 @@ public class FinanceController {
     /**
      * 批量处理用户退款
      */
+    @SaCheckPermission("refundLog.modify")
     @SysLog("批量处理用户微信退款")
     @PostMapping("/batchWxRefund")
     R<?> batchWxRefund(@RequestBody java.util.List<Long> refundLogIds) {
@@ -158,6 +164,7 @@ public class FinanceController {
      * @param param
      * @return
      */
+    @SaCheckPermission("refundLog.modify")
     @SysLog("用户退款申请")
     @PostMapping("/applyRefund")
     @ResponseBody
@@ -177,6 +184,7 @@ public class FinanceController {
     /**
      * 手动触发结算(运维用)
      */
+    @SaCheckPermission("settlement.modify")
     @SysLog("手动触发月度结算")
     @PostMapping("/triggerSettlement")
     public R<?> triggerSettlement() {
@@ -184,6 +192,17 @@ public class FinanceController {
         return R.success();
     }
 
+    /**
+     * 确认结算 — 将待结算金额转入站点可提现余额
+     */
+    @SaCheckPermission("settlement.modify")
+    @SysLog("确认结算")
+    @PostMapping("/confirmSettlement")
+    public R<?> confirmSettlement(@RequestBody Map<String, Long> params) {
+        settlementService.confirmSettlement(params.get("id"));
+        return R.success();
+    }
+
     /**
      * 平台账户信息
      */

+ 2 - 0
car-wash-admin/src/main/java/com/kym/admin/jobs/DeviceHealthProbeJob.java

@@ -4,6 +4,7 @@ import com.kym.entity.WashDevice;
 import com.kym.service.DeviceHealthProbeService;
 import com.kym.service.WashDeviceService;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -31,6 +32,7 @@ public class DeviceHealthProbeJob {
     /**
      * 每3分钟扫描所有故障状态设备,主动探测连通性
      */
+    @Async("AsyncExecutor")
     @Scheduled(cron = "0 */3 * * * ?")
     public void probeFaultDevices() {
         log.debug("设备健康探测-开始");

+ 2 - 0
car-wash-admin/src/main/java/com/kym/admin/jobs/FaultNotificationJob.java

@@ -2,6 +2,7 @@ package com.kym.admin.jobs;
 
 import com.kym.service.FaultNotificationService;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -21,6 +22,7 @@ public class FaultNotificationJob {
         this.faultNotificationService = faultNotificationService;
     }
 
+    @Async("AsyncExecutor")
     @Scheduled(cron = "0 */5 * * * ?")
     public void reconcileFaultNotifications() {
         log.debug("故障通知兜底扫描开始...");

+ 4 - 2
car-wash-admin/src/main/java/com/kym/admin/jobs/SettlementJob.java

@@ -2,6 +2,7 @@ package com.kym.admin.jobs;
 
 import com.kym.service.SettlementService;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -22,9 +23,10 @@ public class SettlementJob {
     }
 
     /**
-     * 每月15日 00:00 执行上月结算
+     * 每月5日 15:30 执行上月结算
      */
-    @Scheduled(cron = "0 0 0 15 * ?")
+    @Async("AsyncExecutor")
+    @Scheduled(cron = "0 30 15 5 * ?")
     public void executeMonthlySettlement() {
         log.info("月度结算定时任务-开始");
         try {

+ 2 - 0
car-wash-admin/src/main/java/com/kym/admin/jobs/StaleOrderReconciliationJob.java

@@ -10,6 +10,7 @@ import com.kym.service.WashOrderService;
 import com.kym.service.awoara.AwoaraService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -50,6 +51,7 @@ public class StaleOrderReconciliationJob {
     /**
      * 每5分钟扫描一次僵死订单
      */
+    @Async("AsyncExecutor")
     @Scheduled(cron = "0 */5 * * * ?")
     public void reconcileStaleOrders() {
         log.info("僵死订单对账-开始");

+ 5 - 2
car-wash-admin/src/main/java/com/kym/admin/jobs/StatJob.java

@@ -3,6 +3,7 @@ package com.kym.admin.jobs;
 import com.kym.service.DailyStatService;
 import com.kym.service.MonthStatService;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -26,9 +27,10 @@ public class StatJob {
 
     /**
      * 统计前一天的日数据
-     * 每天凌晨00:30执行一次
+     * 每天凌晨03:00执行一次
      */
-    @Scheduled(cron = "0 30 0 * * ?")
+    @Async("AsyncExecutor")
+    @Scheduled(cron = "0 0 3 * * ?")
     public void generateDailyStat() {
         log.info("执行站点日统计定时任务-开始");
         var yesterday = LocalDate.now().minusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE);
@@ -39,6 +41,7 @@ public class StatJob {
     /**
      * 月统计,每月5日下午15:00启动,统计上月数据
      */
+    @Async("AsyncExecutor")
     @Scheduled(cron = "0 0 15 5 * ?")
     public void generateMonthStat() {
         log.info("执行站点月统计定时任务-开始");

+ 6 - 2
car-wash-admin/src/main/resources/logback-spring.xml

@@ -11,8 +11,12 @@
     </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/car-wash-admin/car-wash-admin-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>./logs/car-wash-admin/car-wash-admin-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>5GB</totalSizeCap>
+            <maxFileSize>100MB</maxFileSize>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder>
             <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%msg%n</pattern>

+ 1 - 0
car-wash-common/src/main/java/com/kym/common/utils/wx/WxPbUtil.java

@@ -160,6 +160,7 @@ public class WxPbUtil {
             }
         } catch (Exception e) {
             logger.error("WxPbUtil.sendPublicTemplateMessage error ", e);
+            throw new RuntimeException("发送模板消息失败: " + e.getMessage(), e);
         }
     }
 

+ 5 - 0
car-wash-entity/src/main/java/com/kym/entity/SettlementRecord.java

@@ -73,6 +73,11 @@ public class SettlementRecord extends BaseEntity {
      */
     private Integer platformFee;
 
+    /**
+     * 提现手续费(分),微信提现费率 0.6%
+     */
+    private Integer withdrawalFee;
+
     /**
      * 实际结算金额(分)
      */

+ 10 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/DashboardVo.java

@@ -57,6 +57,16 @@ public class DashboardVo {
      */
     private Integer todayWashOrders;
 
+    /**
+     * 当日洗车人数(去重用户数)
+     */
+    private Integer todayWashUsers;
+
+    /**
+     * 当日退款金额
+     */
+    private Integer todayRefundAmount;
+
     /**
      * 当月洗车订单数量
      */

+ 1 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/SettlementRecordVo.java

@@ -25,6 +25,7 @@ public class SettlementRecordVo {
     private Integer closingPendingBalance;
     private Integer platformFeeBase;
     private Integer platformFee;
+    private Integer withdrawalFee;
     private Integer settlementAmount;
     private Integer status;
     private String remark;

+ 9 - 0
car-wash-entity/src/main/resources/sql/v18_add_settlement_company_id.sql

@@ -0,0 +1,9 @@
+-- ====================================================
+-- V18 — 补充 t_settlement_record 表缺少的 company_id 列
+-- 原因:实体类继承 BaseEntity,后者映射 company_id 列,
+-- 但建表脚本漏加了该列,导致月统计查询报错 Unknown column 'company_id'
+-- ====================================================
+
+ALTER TABLE `t_settlement_record`
+    ADD COLUMN `company_id` BIGINT DEFAULT NULL COMMENT '公司(租户)ID'
+    AFTER `remark`;

+ 9 - 0
car-wash-entity/src/main/resources/sql/v19_add_settlement_withdrawal_fee.sql

@@ -0,0 +1,9 @@
+-- ====================================================
+-- V19 — t_settlement_record 新增提现手续费列
+-- 微信提现手续费,费率固定 0.6%(千分之六)
+-- 结算金额 = 可结算总额(扣除平台费后) - 提现手续费
+-- ====================================================
+
+ALTER TABLE `t_settlement_record`
+    ADD COLUMN `withdrawal_fee` INT NOT NULL DEFAULT 0 COMMENT '提现手续费(分),微信提现费率0.6%'
+    AFTER `platform_fee`;

+ 69 - 0
car-wash-entity/src/main/resources/sql/v20_add_finance_idempotency_indexes.sql

@@ -0,0 +1,69 @@
+-- ====================================================
+-- v20: 资金关键表唯一索引(幂等兜底)
+-- 目的:为支付回调 / 退款回调 / 订单结算提供数据库层幂等兜底,
+--       防止并发回调、消息重投导致重复入账 / 重复扣减 / 重复分账。
+-- 涉及表:t_pay_log、t_wallet_detail、t_split_record
+--
+-- 执行前提(重要):
+--   1. 生产执行前先运行下方"查重"SQL,确认无重复数据;
+--      若存在重复,需先人工清理后再执行本脚本(否则建索引会失败)。
+--   2. 建议在业务低峰期执行(大表加唯一索引会锁表/耗时)。
+-- ====================================================
+
+-- ----------------------------
+-- 0. 查重检查(执行前先跑,以下 3 条应全部返回 0 行或空集)
+-- ----------------------------
+-- SELECT out_trade_no, COUNT(*) FROM t_pay_log GROUP BY out_trade_no HAVING COUNT(*) > 1;
+-- SELECT order_no, type, COUNT(*) FROM t_wallet_detail GROUP BY order_no, type HAVING COUNT(*) > 1;
+-- SELECT trade_no, type, from_station_id, to_station_id, COUNT(*) FROM t_split_record
+--     GROUP BY trade_no, type, from_station_id, to_station_id HAVING COUNT(*) > 1;
+
+-- ----------------------------
+-- 1. 幂等建唯一索引
+-- ----------------------------
+DROP PROCEDURE IF EXISTS add_finance_idempotency_indexes;
+DELIMITER //
+CREATE PROCEDURE add_finance_idempotency_indexes()
+BEGIN
+    -- t_pay_log.out_trade_no:微信商户订单号唯一,同一笔支付只允许处理一次
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_pay_log'
+          AND INDEX_NAME = 'uk_out_trade_no'
+    ) THEN
+        ALTER TABLE `t_pay_log` ADD UNIQUE KEY `uk_out_trade_no` (`out_trade_no`);
+    END IF;
+
+    -- t_wallet_detail(order_no, type):同一订单号同类型的钱包流水唯一
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_wallet_detail'
+          AND INDEX_NAME = 'uk_order_no_type'
+    ) THEN
+        ALTER TABLE `t_wallet_detail` ADD UNIQUE KEY `uk_order_no_type` (`order_no`, `type`);
+    END IF;
+
+    -- t_split_record(trade_no, type, from_station_id, to_station_id):
+    -- 同一交易同类型同流向的分账记录唯一(跨店 EXPEND/INCOME 两条 type 不同,互不冲突)
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_split_record'
+          AND INDEX_NAME = 'uk_trade_no_type_from_to'
+    ) THEN
+        ALTER TABLE `t_split_record`
+            ADD UNIQUE KEY `uk_trade_no_type_from_to` (`trade_no`, `type`, `from_station_id`, `to_station_id`);
+    END IF;
+END //
+DELIMITER ;
+CALL add_finance_idempotency_indexes();
+DROP PROCEDURE IF EXISTS add_finance_idempotency_indexes;
+
+-- ====================================================
+-- 验证:
+--   SHOW INDEX FROM t_pay_log WHERE Key_name = 'uk_out_trade_no';
+--   SHOW INDEX FROM t_wallet_detail WHERE Key_name = 'uk_order_no_type';
+--   SHOW INDEX FROM t_split_record WHERE Key_name = 'uk_trade_no_type_from_to';
+-- ====================================================

+ 52 - 0
car-wash-entity/src/main/resources/sql/v21_add_settlement_modify_permission.sql

@@ -0,0 +1,52 @@
+-- ====================================================
+-- v21: 新增 settlement.modify 权限(确认结算)
+-- 背景:FinanceController 的 triggerSettlement / confirmSettlement
+--       将增加 @SaCheckPermission("settlement.modify") 注解,
+--       需要先补齐权限数据,避免现有角色被 403 拦截。
+--
+-- 迁移策略:
+--   1. 新增权限记录 settlement.modify(挂到财务分组 pid=64)
+--   2. 自动授权给所有已拥有 settlement.list 的角色(保持现有功能不中断)
+--   3. 超管角色(id=1)在 v9 中拥有全部权限,此处同样补上
+-- ====================================================
+
+DROP PROCEDURE IF EXISTS add_settlement_modify_permission;
+DELIMITER //
+CREATE PROCEDURE add_settlement_modify_permission()
+BEGIN
+    -- 1. 新增权限记录(幂等)
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_permission'
+    ) THEN
+        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 't_permission 表不存在,请先执行 init.sql';
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM t_permission WHERE value = 'settlement.modify'
+    ) THEN
+        INSERT INTO `t_permission` (`id`, `company_id`, `name`, `value`, `pid`, `weight`)
+        VALUES (93, NULL, '确认结算', 'settlement.modify', 64, 7);
+    END IF;
+
+    -- 2. 授权给已拥有 settlement.list 的角色(管道分隔权限字符串,幂等)
+    UPDATE `t_role`
+    SET `permissions` = CONCAT(`permissions`, '|settlement.modify')
+    WHERE FIND_IN_SET('settlement.list', REPLACE(IFNULL(`permissions`, ''), '|', ',')) > 0
+      AND FIND_IN_SET('settlement.modify', REPLACE(IFNULL(`permissions`, ''), '|', ',')) = 0;
+
+    -- 3. 超管角色兜底(若其权限字符串未包含新权限)
+    UPDATE `t_role`
+    SET `permissions` = CONCAT(IFNULL(`permissions`, ''), '|settlement.modify')
+    WHERE `id` = 1
+      AND FIND_IN_SET('settlement.modify', REPLACE(IFNULL(`permissions`, ''), '|', ',')) = 0;
+END //
+DELIMITER ;
+CALL add_settlement_modify_permission();
+DROP PROCEDURE IF EXISTS add_settlement_modify_permission;
+
+-- ====================================================
+-- 验证:
+--   SELECT * FROM t_permission WHERE value = 'settlement.modify';
+--   SELECT id, role_name, permissions FROM t_role WHERE permissions LIKE '%settlement.modify%';
+-- ====================================================

+ 6 - 7
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/PaymentController.java

@@ -27,7 +27,7 @@ public class PaymentController {
     }
 
     /**
-     * 充值
+     * 充值(归属站点由服务端按用户归属确定,客户端传值无效)
      *
      * @param rechargeConfigId
      * @return
@@ -35,8 +35,8 @@ public class PaymentController {
     @ApiLog("用户充值微信支付")
     @GetMapping("/wxPay")
     @ResponseBody
-    R<?> prepay(@RequestParam Long rechargeConfigId, @RequestParam(value = "stationId", required = false) String stationId) {
-        return R.success(wxPayService.wxPay(rechargeConfigId, stationId));
+    R<?> prepay(@RequestParam Long rechargeConfigId) {
+        return R.success(wxPayService.wxPay(rechargeConfigId));
     }
 
     @ApiLog(value = "微信回调", ignoreParams = true)
@@ -49,14 +49,13 @@ public class PaymentController {
 
 
     /**
-     * 专属优惠活动充值
+     * 专属优惠活动充值(归属站点由服务端按用户归属确定,客户端传值无效)
      */
     @ApiLog("用户优惠活动充值微信支付")
     @GetMapping("/promotionPay")
     @ResponseBody
-    R<?> promotionPay(@RequestParam String promotionToken,
-                      @RequestParam(value = "stationId", required = false) String stationId) {
-        return R.success(wxPayService.promotionPay(promotionToken, stationId));
+    R<?> promotionPay(@RequestParam String promotionToken) {
+        return R.success(wxPayService.promotionPay(promotionToken));
     }
 
     @ApiLog("用户申请退款")

+ 6 - 2
car-wash-miniapp/src/main/resources/logback-spring.xml

@@ -11,8 +11,12 @@
     </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/car-wash-miniapp/car-wash-miniapp-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>./logs/car-wash-miniapp/car-wash-miniapp-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>5GB</totalSizeCap>
+            <maxFileSize>100MB</maxFileSize>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder>
             <pattern>%d{HH:mm:ss.SSS}|%thread|%-5level|%F:%L|%X{ip}|%X{seq}|%msg%n</pattern>

+ 32 - 16
car-wash-mp/src/components/custom-service/index.vue

@@ -12,7 +12,17 @@
       <view class="cs-button">
         <uv-icon name="kefu-ermai" color="#FFFFFF" size="24"></uv-icon>
       </view>
-      <view class="contact-btn" @click.stop="handleContactClick" />
+      <!-- 工作时间:原生 button 直接调起小程序客服 -->
+      <button
+        v-if="inService"
+        class="contact-btn"
+        open-type="contact"
+        send-message-title="YesWash洗车客服"
+        session-from=""
+        @click.stop
+      />
+      <!-- 非工作时间:先弹确认提示 -->
+      <view v-else class="contact-btn" @click.stop="confirmVisible = true" />
     </movable-view>
 
     <!-- 非工作时间确认弹窗 -->
@@ -38,7 +48,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, onMounted, onUnmounted } from 'vue'
 
 const POS_KEY = 'kefuPos'
 const confirmVisible = ref(false)
@@ -50,6 +60,18 @@ const isInServiceHours = (): boolean => {
   return current >= 9 * 60 + 30 && current <= 21 * 60 + 30
 }
 
+/* 响应式在线状态,定时刷新以跨越 9:30/21:30 边界 */
+const inService = ref(isInServiceHours())
+let serviceTimer: ReturnType<typeof setInterval> | null = null
+onMounted(() => {
+  serviceTimer = setInterval(() => {
+    inService.value = isInServiceHours()
+  }, 30000)
+})
+onUnmounted(() => {
+  if (serviceTimer) clearInterval(serviceTimer)
+})
+
 /* 实时拖拽位置(非响应式,避免与原生拖拽冲突) */
 let dragX = 600
 let dragY = 800
@@ -89,20 +111,6 @@ function onTouchEnd() {
   snapPos.value = { x: snapX, y: snapY }
   uni.setStorageSync(POS_KEY, { x: snapX, y: snapY })
 }
-
-const handleContactClick = () => {
-  if (isInServiceHours()) {
-    // #ifdef MP-WEIXIN
-    wx.openCustomerServiceConversation({
-      sessionFrom: '',
-      showMessageCard: true,
-      sendMessageTitle: 'YesWash洗车客服'
-    })
-    // #endif
-    return
-  }
-  confirmVisible.value = true
-}
 </script>
 
 <style scoped lang="scss">
@@ -152,6 +160,14 @@ const handleContactClick = () => {
   z-index: 2;
   padding: 0;
   margin: 0;
+  background: none;
+  border: none;
+  border-radius: 0;
+  line-height: normal;
+
+  &::after {
+    border: none;
+  }
 }
 
 .confirm-overlay {

+ 5 - 0
car-wash-service/src/main/java/com/kym/service/SettlementService.java

@@ -23,4 +23,9 @@ public interface SettlementService extends MyBaseService<SettlementRecord> {
      * 结算记录列表
      */
     PageBean<SettlementRecordVo> listSettlementRecords(SettlementQueryParam params);
+
+    /**
+     * 确认结算 — 将待结算金额转入站点可提现余额,状态改为已结算
+     */
+    void confirmSettlement(Long recordId);
 }

+ 5 - 0
car-wash-service/src/main/java/com/kym/service/WashOrderService.java

@@ -58,6 +58,11 @@ public interface WashOrderService extends MyBaseService<WashOrder> {
 
     Map<String, Integer> countMonthActiveUsers(LocalDate statDay);
 
+    /**
+     * 统计时间段内站点活跃用户(去重),用于跨天汇总
+     */
+    Map<String, Integer> countDistinctActiveUsers(LocalDate startDate, LocalDate endDate);
+
     WashOrder getOrderInProgressByUserId(long userId);
 
     List<StationTrendVo> stationTrend(StatQueryParam params);

+ 3 - 1
car-wash-service/src/main/java/com/kym/service/aliyun/lot/AliyunLotClient.java

@@ -206,7 +206,9 @@ public class AliyunLotClient {
         com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
                 .setRegionId("cn-shanghai")
                 .setAccessKeyId(accessKey)
-                .setAccessKeySecret(accessSecret);
+                .setAccessKeySecret(accessSecret)
+                .setConnectTimeout(5000)
+                .setReadTimeout(10000);
         rRpcClient = new com.aliyun.iot20180120.Client(config);
     }
 

+ 3 - 0
car-wash-service/src/main/java/com/kym/service/impl/AdminUserServiceImpl.java

@@ -90,6 +90,9 @@ public class AdminUserServiceImpl extends MyBaseServiceImpl<AdminUserMapper, Adm
     @Override
     public R<?> login(String mobilePhone, String password) {
         var user = lambdaQuery().eq(AdminUser::getMobilePhone, mobilePhone).one();
+        if (user == null) {
+            return R.failed(ResponseEnum.LOGIN_FAILED);
+        }
         if (user.getStatus() == AdminUser.STATUS_禁用) {
             throw new BusinessException("用户状态异常,无法登录");
         }

+ 8 - 1
car-wash-service/src/main/java/com/kym/service/impl/DailyStatServiceImpl.java

@@ -215,10 +215,17 @@ public class DailyStatServiceImpl extends MyBaseServiceImpl<DailyStatMapper, Dai
         var totalConsumption = list.stream().filter(s -> s.getConsumption() != null).mapToInt(DailyStat::getConsumption).sum();
         var ordersCount = list.stream().filter(s -> s.getOrdersCount() != null).mapToInt(DailyStat::getOrdersCount).sum();
         var registrations = list.stream().filter(s -> s.getRegistrations() != null).mapToInt(DailyStat::getRegistrations).sum();
-        var activeUserCount = list.stream().filter(s -> s.getActiveUserCount() != null).mapToInt(DailyStat::getActiveUserCount).sum();
         var rechargeCount = list.stream().filter(s -> s.getRechargeCount() != null).mapToInt(DailyStat::getRechargeCount).sum();
         var refundCount = list.stream().filter(s -> s.getRefundCount() != null).mapToInt(DailyStat::getRefundCount).sum();
 
+        // 活跃用户:跨天需要去重,不能简单 SUM 日统计值,改为从订单源表 GROUP BY station_id, user_id 后计数
+        var activeStartDate = params.getStartDate() != null ? params.getStartDate() : LocalDate.of(2020, 1, 1);
+        var activeEndDate = params.getEndDate() != null ? params.getEndDate() : LocalDate.now();
+        var activeUserMap = washOrderService.countDistinctActiveUsers(activeStartDate, activeEndDate);
+        var activeUserCount = CommUtil.isNotEmptyAndNull(params.getStationId())
+                ? activeUserMap.getOrDefault(params.getStationId(), 0)
+                : activeUserMap.values().stream().mapToInt(Integer::intValue).sum();
+
         return new DailyStatSummaryVo()
                 .setTotalIncome(totalIncome)
                 .setTotalConsumption(totalConsumption)

+ 21 - 11
car-wash-service/src/main/java/com/kym/service/impl/FaultNotificationServiceImpl.java

@@ -14,7 +14,6 @@ import com.kym.service.MpMsgTemplateService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
 import java.time.Duration;
@@ -53,26 +52,22 @@ public class FaultNotificationServiceImpl implements FaultNotificationService {
     }
 
     @Override
-    @Async
     public void handleOfflineFault(String stationId, String deviceName) {
         handleFault(stationId, deviceName, FaultType.OFFLINE, null);
     }
 
     @Override
-    @Async
     public void handleOnlineRecovery(String stationId, String deviceName) {
         handleRecovery(stationId, deviceName, FaultType.OFFLINE);
     }
 
     @Override
-    @Async
     public void handleResourceShortage(String stationId, String deviceName, String faultType) {
         FaultType type = FaultType.fromCode(faultType);
         handleFault(stationId, deviceName, type, null);
     }
 
     @Override
-    @Async
     public void handleResourceRecovery(String stationId, String deviceName, String faultType) {
         FaultType type = FaultType.fromCode(faultType);
         handleRecovery(stationId, deviceName, type);
@@ -99,7 +94,7 @@ public class FaultNotificationServiceImpl implements FaultNotificationService {
 
                 var redisKey = buildAntiDupKey(record.getFaultType(), record.getDeviceName(), record.getStationId());
                 if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(redisKey))) {
-                    faultRecordService.markNotified(record.getId());
+                    log.debug("反重key存在,跳过等待下次兜底: recordId={}", record.getId());
                 } else {
                     doNotify(record);
                 }
@@ -135,7 +130,9 @@ public class FaultNotificationServiceImpl implements FaultNotificationService {
     private void handleRecovery(String stationId, String deviceName, FaultType faultType) {
         var record = faultRecordService.getUnrecovered(stationId, deviceName, faultType.getCode());
         if (record == null) {
-            log.debug("未找到未恢复的故障记录: stationId={}, deviceName={}, faultType={}", stationId, deviceName, faultType.getCode());
+            log.debug("未找到未恢复的故障记录,清理可能残留的反重key: stationId={}, deviceName={}, faultType={}",
+                    stationId, deviceName, faultType.getCode());
+            stringRedisTemplate.delete(buildAntiDupKey(faultType.getCode(), deviceName, stationId));
             return;
         }
 
@@ -166,18 +163,25 @@ public class FaultNotificationServiceImpl implements FaultNotificationService {
         }
 
         var params = buildFaultAlertParams(record);
+        int successCount = 0;
         for (var openid : subscribers) {
             try {
                 WxPbUtil.sendPublicTemplateMessage(openid, template.getTemplateId(), params, "", "");
+                successCount++;
             } catch (Exception e) {
                 log.error("发送故障提醒失败: openid={}, recordId={}", openid, record.getId(), e);
             }
         }
 
-        faultRecordService.markNotified(record.getId());
-        stringRedisTemplate.opsForValue().set(
-                buildAntiDupKey(record.getFaultType(), record.getDeviceName(), record.getStationId()),
-                "1", ANTI_DUP_TTL);
+        if (successCount > 0) {
+            faultRecordService.markNotified(record.getId());
+            stringRedisTemplate.opsForValue().set(
+                    buildAntiDupKey(record.getFaultType(), record.getDeviceName(), record.getStationId()),
+                    "1", ANTI_DUP_TTL);
+        } else {
+            log.warn("故障提醒全部发送失败({}/{}), 保留未通知状态等待下次兜底: recordId={}",
+                    successCount, subscribers.size(), record.getId());
+        }
     }
 
     private void sendRecoveryNotification(FaultRecord record) {
@@ -193,13 +197,19 @@ public class FaultNotificationServiceImpl implements FaultNotificationService {
         }
 
         var params = buildFaultRecoverParams(record);
+        int successCount = 0;
         for (var openid : subscribers) {
             try {
                 WxPbUtil.sendPublicTemplateMessage(openid, template.getTemplateId(), params, "", "");
+                successCount++;
             } catch (Exception e) {
                 log.error("发送故障恢复提醒失败: openid={}, recordId={}", openid, record.getId(), e);
             }
         }
+        if (successCount == 0) {
+            log.warn("故障恢复提醒全部发送失败({}/{}): recordId={}",
+                    successCount, subscribers.size(), record.getId());
+        }
     }
 
     private Map<String, String> buildFaultAlertParams(FaultRecord record) {

+ 8 - 4
car-wash-service/src/main/java/com/kym/service/impl/OrderSettlementServiceImpl.java

@@ -59,9 +59,13 @@ public class OrderSettlementServiceImpl implements OrderSettlementService {
     public void settleOrder(WashOrder washOrder, OrderInfo orderInfo) {
         log.info("执行订单结算,订单:{},结算信息:{}", orderInfo.getOrder_id(), orderInfo);
 
-        // 幂等保护:重新加载订单并检查是否已结算
-        var freshOrder = washOrderService.lambdaQuery().eq(WashOrder::getId, washOrder.getId()).one();
-        if (freshOrder != null && Integer.valueOf(WashOrder.PAY_STATUS_已支付).equals(freshOrder.getPayStatus())) {
+        // 幂等门闩:条件更新占位(未支付 → 已支付),并发结算时第二个事务更新 0 行直接跳过
+        boolean claimed = washOrderService.lambdaUpdate()
+                .eq(WashOrder::getId, washOrder.getId())
+                .eq(WashOrder::getPayStatus, WashOrder.PAY_STATUS_未支付)
+                .set(WashOrder::getPayStatus, WashOrder.PAY_STATUS_已支付)
+                .update();
+        if (!claimed) {
             log.warn("订单:{},已结算,跳过重复结算", orderInfo.getOrder_id());
             return;
         }
@@ -150,7 +154,7 @@ public class OrderSettlementServiceImpl implements OrderSettlementService {
             var url = DOMAIN + "/api/parking-coupon?code=" + code;
             mpMsgTemplateService.sendParkingCouponMsg(washOrder, url);
         } else {
-            mpMsgTemplateService.sendOrderCompletedMsg(washOrder, account.getBalance());
+            mpMsgTemplateService.sendOrderCompletedMsg(washOrder, account.getBalance() - amountReceived);
         }
     }
 

+ 81 - 41
car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java

@@ -12,6 +12,7 @@ import com.kym.mapper.SettlementRecordMapper;
 import com.kym.service.SettlementService;
 import com.kym.service.PlatformAccountService;
 import com.kym.service.PlatformRevenueRecordService;
+import com.kym.service.cache.KymCache;
 import com.kym.service.SplitRecordService;
 import com.kym.service.StationAccountService;
 import com.kym.service.mybatisplus.MyBaseServiceImpl;
@@ -43,7 +44,9 @@ import java.util.stream.Collectors;
 public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMapper, SettlementRecord> implements SettlementService {
 
     private static final BigDecimal PLATFORM_FEE_RATE = new BigDecimal("0.1");
+    private static final BigDecimal WITHDRAWAL_FEE_RATE = new BigDecimal("0.006");
     private static final DateTimeFormatter PERIOD_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
+    private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     private final SplitRecordService splitRecordService;
     private final StationAccountService stationAccountService;
@@ -68,13 +71,6 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
 
         log.info("开始执行结算,周期:{}", period);
 
-        // 幂等检查:如果本期已有结算记录则跳过
-        long settledCount = lambdaQuery().eq(SettlementRecord::getSettlementPeriod, period).count();
-        if (settledCount > 0) {
-            log.warn("本期 {} 已有 {} 条结算记录,跳过重复结算", period, settledCount);
-            return;
-        }
-
         // 汇总本期各站点分账数据
         var allRecords = splitRecordService.lambdaQuery()
                 .ge(SplitRecord::getCreateTime, periodStart)
@@ -97,6 +93,16 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
     }
 
     private void doStationSettlement(String stationId, String period, List<SplitRecord> allRecords) {
+        // 站点+周期幂等:已有结算记录(不论状态)则跳过
+        long existing = lambdaQuery()
+                .eq(SettlementRecord::getStationId, stationId)
+                .eq(SettlementRecord::getSettlementPeriod, period)
+                .count();
+        if (existing > 0) {
+            log.info("站点 {} 周期 {} 已有结算记录,跳过", stationId, period);
+            return;
+        }
+
         int totalRecharge = sumByType(allRecords, stationId, SplitRecord.TYPE_RECHARGE, true);
         int totalRefund = sumByType(allRecords, stationId, SplitRecord.TYPE_REFUND, false);
         int totalCrossExpend = sumByType(allRecords, stationId, SplitRecord.TYPE_CROSS_EXPEND, false);
@@ -106,15 +112,17 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
             return;
         }
 
-        // 读取上期期末余额作为本期期初
         int openingBalance = getOpeningBalance(stationId, period);
 
-        // 平台服务费基数(仅基于本期流入,不含上期结转)
-        int feeBase = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome;
-        int platformFee = feeBase > 0 ? new BigDecimal(feeBase).multiply(PLATFORM_FEE_RATE).setScale(0, RoundingMode.DOWN).intValue() : 0;
-
-        // 可结算总额 = 期初结转 + 本期流入 - 平台费
-        int available = openingBalance + feeBase - platformFee;
+        int netInflow = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome;
+        int withdrawalFee = netInflow > 0
+                ? new BigDecimal(netInflow).multiply(WITHDRAWAL_FEE_RATE).setScale(0, RoundingMode.DOWN).intValue()
+                : 0;
+        int afterWechat = openingBalance + netInflow - withdrawalFee;
+        int platformFee = afterWechat > 0
+                ? new BigDecimal(afterWechat).multiply(PLATFORM_FEE_RATE).setScale(0, RoundingMode.DOWN).intValue()
+                : 0;
+        int settlementAmount = afterWechat - platformFee;
 
         SettlementRecord record = new SettlementRecord()
                 .setStationId(stationId)
@@ -124,41 +132,25 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
                 .setTotalCrossIncome(totalCrossIncome)
                 .setTotalCrossExpend(totalCrossExpend)
                 .setOpeningPendingBalance(openingBalance)
-                .setPlatformFeeBase(feeBase)
-                .setPlatformFee(platformFee);
+                .setPlatformFeeBase(afterWechat)
+                .setPlatformFee(platformFee)
+                .setWithdrawalFee(withdrawalFee);
 
-        if (available > 0) {
-            // 正常结算
-            record.setSettlementAmount(available)
+        if (settlementAmount > 0) {
+            record.setSettlementAmount(settlementAmount)
                     .setClosingPendingBalance(0)
-                    .setStatus(SettlementRecord.STATUS_已结算);
-
-            // 增加站点可提现金额
-            stationAccountService.lambdaUpdate()
-                    .setSql("available_balance = available_balance + {0}", available)
-                    .eq(StationAccount::getStationId, stationId)
-                    .update();
-
-            save(record);
-
-            // 平台服务费收入记录
-            if (platformFee > 0) {
-                platformRevenueRecordService.saveRecord(record, stationId);
-                platformAccountService.addRevenue(platformFee);
-            }
-
-            log.info("站点 {} 结算成功,金额:{} 分,平台费:{} 分", stationId, available, platformFee);
+                    .setStatus(SettlementRecord.STATUS_待结算);
         } else {
-            // 异常结算
             record.setSettlementAmount(0)
-                    .setClosingPendingBalance(available)
+                    .setClosingPendingBalance(settlementAmount)
                     .setStatus(SettlementRecord.STATUS_异常结算)
                     .setRemark("结算金额为负,结转至下期");
+        }
 
-            save(record);
+        save(record);
 
-            log.warn("站点 {} 异常结算,结转金额:{} 分", stationId, available);
-        }
+        log.info("站点 {} 周期 {} 结算记录已生成,状态:{},金额:{} 分,平台费:{} 分,提现手续费:{} 分",
+                stationId, period, record.getStatus(), record.getSettlementAmount(), platformFee, withdrawalFee);
     }
 
     /**
@@ -198,8 +190,56 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
         var voList = res.stream().map(item -> {
             var vo = new SettlementRecordVo();
             BeanUtils.copyProperties(item, vo);
+            vo.setStationName(KymCache.INSTANCE.getStationNameById(item.getStationId()));
+            if (item.getCreateTime() != null) {
+                vo.setCreateTime(item.getCreateTime().format(DTF));
+            }
+            if (item.getUpdateTime() != null) {
+                vo.setUpdateTime(item.getUpdateTime().format(DTF));
+            }
             return vo;
         }).toList();
         return new PageBean<>(voList);
     }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void confirmSettlement(Long recordId) {
+        var record = getById(recordId);
+        if (record == null) {
+            throw new IllegalArgumentException("结算记录不存在");
+        }
+        if (record.getStatus() == SettlementRecord.STATUS_已结算) {
+            throw new IllegalArgumentException("该记录已确认,不可重复操作");
+        }
+        if (record.getStatus() != SettlementRecord.STATUS_待结算) {
+            throw new IllegalArgumentException("仅待结算记录可确认");
+        }
+
+        // 幂等门闩:条件更新(仅待结算可确认),并发重复确认时第二个事务更新 0 行直接失败,
+        // 余额转入与平台费记录不会重复执行
+        boolean claimed = lambdaUpdate()
+                .set(SettlementRecord::getStatus, SettlementRecord.STATUS_已结算)
+                .eq(SettlementRecord::getId, recordId)
+                .eq(SettlementRecord::getStatus, SettlementRecord.STATUS_待结算)
+                .update();
+        if (!claimed) {
+            throw new IllegalArgumentException("该记录已确认,不可重复操作");
+        }
+
+        // 转入站点可提现余额
+        stationAccountService.lambdaUpdate()
+                .setSql("available_balance = available_balance + {0}", record.getSettlementAmount())
+                .eq(StationAccount::getStationId, record.getStationId())
+                .update();
+
+        // 平台服务费收入记录
+        if (record.getPlatformFee() != null && record.getPlatformFee() > 0) {
+            platformRevenueRecordService.saveRecord(record, record.getStationId());
+            platformAccountService.addRevenue(record.getPlatformFee());
+        }
+
+        log.info("结算确认完成,记录ID:{},站点:{},周期:{},金额:{} 分",
+                recordId, record.getStationId(), record.getSettlementPeriod(), record.getSettlementAmount());
+    }
 }

+ 9 - 0
car-wash-service/src/main/java/com/kym/service/impl/StatServiceImpl.java

@@ -147,6 +147,13 @@ public class StatServiceImpl implements StatService {
                 .list().stream().filter(r -> r.getAmount() != null).mapToInt(SplitRecord::getAmount).sum();
         var todayIncome = todayRechargeAndCrossIncome - todayRefund - todayCrossExpend;
 
+        // 今日洗车人数(去重用户)
+        var todayWashUsers = (int) todayOrders.stream()
+                .map(WashOrder::getUserId)
+                .filter(java.util.Objects::nonNull)
+                .distinct()
+                .count();
+
         // 站点今日注册人数
         var todayRegistrations = userService.countDailyRegister(LocalDate.now(), stationId);
 
@@ -211,6 +218,8 @@ public class StatServiceImpl implements StatService {
         dashboardVo.setTodayIncome(todayIncome);
         dashboardVo.setTodayConsumptionAmount(todayConsumptionAmount);
         dashboardVo.setTodayWashOrders(todayOrders.size());
+        dashboardVo.setTodayWashUsers(todayWashUsers);
+        dashboardVo.setTodayRefundAmount(todayRefund);
         dashboardVo.setTodayRegisteredMembers(CommUtil.isEmptyOrNull(todayRegistrations) ? 0 : todayRegistrations.getOrDefault(stationId, 0));
 
         dashboardVo.setMonthIncome(monthIncome);

+ 12 - 3
car-wash-service/src/main/java/com/kym/service/impl/StationAccountServiceImpl.java

@@ -68,14 +68,23 @@ public class StationAccountServiceImpl extends MyBaseServiceImpl<StationAccountM
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void applyWithdrawn(WithdrawnQueryParam params) {
+        // 金额正数校验,防止负数绕过余额检查反向增加余额
+        if (params.getAmount() == null || params.getAmount() <= 0) {
+            throw new BusinessException("提现金额必须大于0");
+        }
         var stationAccount = getStationAccount(params.getStationId());
-        if (stationAccount.getAvailableBalance() == null || stationAccount.getAvailableBalance() < params.getAmount()) {
-            throw new BusinessException("提现金额超出可提现金额!");
+        if (stationAccount == null) {
+            throw new BusinessException("站点账户不存在");
         }
-        lambdaUpdate()
+        // 原子条件扣减:余额不足或并发提现时影响行数为 0,不会把可提现余额扣成负数
+        boolean updated = lambdaUpdate()
                 .setSql("available_balance = available_balance - {0}, withdrawn_frozen_amount = withdrawn_frozen_amount + {0}", params.getAmount())
                 .eq(StationAccount::getStationId, params.getStationId())
+                .ge(StationAccount::getAvailableBalance, params.getAmount())
                 .update();
+        if (!updated) {
+            throw new BusinessException("提现金额超出可提现金额!");
+        }
 
         var withdrawnRecord = new WithdrawnRecord()
                 .setStationId(params.getStationId())

+ 11 - 0
car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java

@@ -494,6 +494,17 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
                 .collect(Collectors.groupingBy(WashOrder::getStationId, Collectors.summingInt(o -> 1)));
     }
 
+    @Override
+    public Map<String, Integer> countDistinctActiveUsers(LocalDate startDate, LocalDate endDate) {
+        LambdaQueryWrapper<WashOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.select(WashOrder::getUserId, WashOrder::getStationId);
+        wrapper.ge(WashOrder::getStartTime, LocalDateTime.of(startDate, LocalTime.MIN));
+        wrapper.lt(WashOrder::getStartTime, LocalDateTime.of(endDate.plusDays(1), LocalTime.MIN));
+        wrapper.groupBy(WashOrder::getStationId, WashOrder::getUserId);
+        return list(wrapper).stream()
+                .collect(Collectors.groupingBy(WashOrder::getStationId, Collectors.summingInt(o -> 1)));
+    }
+
     @Override
     public WashOrder getOrderInProgressByUserId(long userId) {
         return lambdaQuery()

+ 2 - 2
car-wash-service/src/main/java/com/kym/service/wechat/WxPayService.java

@@ -26,12 +26,12 @@ public interface WxPayService {
     ResponseEntity<Object> wxRefundNotify(HttpServletRequest request);
 
     @Transactional
-    PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId, String stationId);
+    PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId);
 
     ResponseEntity<Object> wxNotify(HttpServletRequest request) throws IOException;
 
     @Transactional
-    PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId);
+    PrepayWithRequestPaymentResponse promotionPay(String promotionToken);
 
     void offlineRecharge(Long userId, Integer amount, Integer grantsAmount, String remark);
 

+ 98 - 51
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -41,6 +41,7 @@ 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.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
@@ -90,6 +91,7 @@ public class WxPayServiceImpl implements WxPayService {
     private final WashOrderService washOrderService;
     private final MpMsgTemplateService mpMsgTemplateService;
     private final RechargePromotionService rechargePromotionService;
+    private final UserService userService;
 
 
     /**
@@ -104,7 +106,8 @@ public class WxPayServiceImpl implements WxPayService {
                             ActivityService activityService,
                             RechargeConfigService rechargeConfigService, SplitRecordService splitRecordService,
                             WashOrderService washOrderService, MpMsgTemplateService mpMsgTemplateService,
-                            RechargePromotionService rechargePromotionService) {
+                            RechargePromotionService rechargePromotionService,
+                            UserService userService) {
         this.conf = conf;
         this.walletDetailService = walletDetailService;
         this.payLogService = payLogService;
@@ -115,6 +118,7 @@ public class WxPayServiceImpl implements WxPayService {
         this.washOrderService = washOrderService;
         this.mpMsgTemplateService = mpMsgTemplateService;
         this.rechargePromotionService = rechargePromotionService;
+        this.userService = userService;
     }
 
     /**
@@ -213,7 +217,7 @@ public class WxPayServiceImpl implements WxPayService {
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId, String stationId) {
+    public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId) {
         // 充值配置
         var rechargeConfig = rechargeConfigService.getById(rechargeConfigId);
 
@@ -224,6 +228,8 @@ public class WxPayServiceImpl implements WxPayService {
         var rechargeAmount = rechargeConfig.getRechargeAmount();
         var openid = StpUtil.getSession().getString("openid");
         var userId = StpUtil.getLoginIdAsLong();
+        // 充值归属站点以服务端用户归属为准,不信任客户端传值(防止分账归属被篡改)
+        var stationId = getUserStationId(userId);
         // 生成订单号
         String outTradeNo = OrderUtils.getOrderNo();
         // 创建钱包流水
@@ -261,7 +267,7 @@ public class WxPayServiceImpl implements WxPayService {
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId) {
+    public PrepayWithRequestPaymentResponse promotionPay(String promotionToken) {
         var promotion = rechargePromotionService.getByToken(promotionToken);
         if (promotion == null) {
             throw new BusinessException("优惠活动不存在或已过期");
@@ -273,6 +279,8 @@ public class WxPayServiceImpl implements WxPayService {
         }
 
         var openid = StpUtil.getSession().getString("openid");
+        // 充值归属站点以服务端用户归属为准,不信任客户端传值(防止分账归属被篡改)
+        var stationId = getUserStationId(userId);
         var outTradeNo = OrderUtils.getOrderNo();
 
         var walletDetail = new WalletDetail()
@@ -314,6 +322,18 @@ public class WxPayServiceImpl implements WxPayService {
         return jsapiService.queryOrderById(request);
     }
 
+    /**
+     * 服务端获取用户归属站点(KymCache 优先,未命中查库兜底),用于充值分账归属
+     */
+    private String getUserStationId(Long userId) {
+        var stationId = KymCache.INSTANCE.getUserStationId(userId);
+        if (stationId == null) {
+            var user = userService.getById(userId);
+            stationId = user != null ? user.getStationId() : null;
+        }
+        return stationId;
+    }
+
     /**
      * 关闭订单
      *
@@ -371,24 +391,8 @@ public class WxPayServiceImpl implements WxPayService {
                     grantsAmount = rechargeConfig.getGrantsAmount();
                 }
 
-                // 更新余额(赠款计入不可退优惠金额)
-                var account = accountService.getAccountByUserId(walletDetail.getUserId());
-                accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
-                        .eq(Account::getUserId, walletDetail.getUserId()).update();
-
-                walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
-                walletDetail.setSource("WX_PAY");
-                walletDetail.setCurrency(transaction.getAmount().getCurrency());
-                walletDetail.setAmount(transaction.getAmount().getTotal());
-                walletDetail.setGrantsAmount(grantsAmount);
-                walletDetail.setBeforeBalance(account.getBalance());
-                walletDetail.setAfterBalance(account.getBalance() + walletDetail.getAmount());
-                walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
-                walletDetail.setAfterGrantsBalance(account.getGrantsBalance() + grantsAmount);
-                walletDetail.setTransactionTime(successTime);
-                walletDetailService.updateById(walletDetail);
-
-                // 支付记录
+                // 支付记录(幂等门闩:必须先落库 payLog,唯一索引 uk_out_trade_no 兜底并发重复回调;
+                // 若放在余额更新之后,重复回调会先重复加余额再被拦截)
                 var payLog = new PayLog();
                 payLog.setUserId(walletDetail.getUserId());
                 payLog.setOpenid(transaction.getPayer().getOpenid());
@@ -405,7 +409,30 @@ public class WxPayServiceImpl implements WxPayService {
                 payLog.setCurrency(transaction.getAmount().getCurrency());
                 payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
                 payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
-                payLogService.save(payLog);
+                try {
+                    payLogService.save(payLog);
+                } catch (DuplicateKeyException e) {
+                    // 并发重复回调:唯一索引拦截,视为已处理
+                    LOGGER.warn("微信支付回调重复投递已被唯一索引拦截,outTradeNo={}", transaction.getOutTradeNo());
+                    return ResponseEntity.status(HttpStatus.OK).build();
+                }
+
+                // 更新余额(赠款计入不可退优惠金额)
+                var account = accountService.getAccountByUserId(walletDetail.getUserId());
+                accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
+                        .eq(Account::getUserId, walletDetail.getUserId()).update();
+
+                walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
+                walletDetail.setSource("WX_PAY");
+                walletDetail.setCurrency(transaction.getAmount().getCurrency());
+                walletDetail.setAmount(transaction.getAmount().getTotal());
+                walletDetail.setGrantsAmount(grantsAmount);
+                walletDetail.setBeforeBalance(account.getBalance());
+                walletDetail.setAfterBalance(account.getBalance() + walletDetail.getAmount());
+                walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
+                walletDetail.setAfterGrantsBalance(account.getGrantsBalance() + grantsAmount);
+                walletDetail.setTransactionTime(successTime);
+                walletDetailService.updateById(walletDetail);
 
                 // V2 结算方案:充值资金记录分账流水,结算日统一处理,不再即时转入站点账户
                 // 无归属站点的用户充值暂不生成分账记录,首次消费时追溯补建
@@ -429,15 +456,14 @@ public class WxPayServiceImpl implements WxPayService {
                 LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
                 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
             }
-        } catch (Exception e) {
-            if (e instanceof ValidationException) {
-                // 签名验证失败,返回 401 UNAUTHORIZED 状态码
-                LOGGER.error("微信支付通知验签失败", e);
-            }
-            if (e instanceof BusinessException) {
-                LOGGER.error("业务异常", e);
-            }
+        } catch (ValidationException e) {
+            // 签名验证失败,返回 401 UNAUTHORIZED 状态码
+            LOGGER.error("微信支付通知验签失败", e);
             return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", HttpStatus.UNAUTHORIZED, "message", "验签失败"));
+        } catch (Exception e) {
+            // 业务处理失败返回 500,微信会重试投递;重试时由唯一索引/幂等快路径保证不会重复入账
+            LOGGER.error("微信支付通知处理异常", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理失败"));
         }
     }
 
@@ -502,9 +528,14 @@ public class WxPayServiceImpl implements WxPayService {
             throw new BusinessException("存在未完结的订单,请完成所有订单完结之后重试");
         }
 
-        var account = accountService.getAccountByUserId(userId);
-        if (account.getBalance() <= 0) {
-            throw new BusinessException("账户余额不足,无需退款");
+        // 行锁读取账户,防止并发退款请求同时通过余额校验造成重复退款
+        var account = accountService.lambdaQuery().eq(Account::getUserId, userId).last("FOR UPDATE").one();
+        if (account == null) {
+            throw new BusinessException("用户账户不存在");
+        }
+        // 退款金额以充值余额为准(纯赠款余额不可退),校验与冻结/退款口径一致
+        if (account.getRechargeBalance() <= 0) {
+            throw new BusinessException("充值余额不足,无需退款");
         }
         // // 校验余额大于优惠金额
         // if (account.getBalance() <= account.getDiscountAmount()) {
@@ -699,9 +730,13 @@ public class WxPayServiceImpl implements WxPayService {
             RefundNotification refundNotification = ((NotificationParser) notifyRes[1]).parse((RequestParam) notifyRes[0], RefundNotification.class);
             LOGGER.info("微信退款回调{}:验签解密完毕,数据:\n{}", notifyRes[2], refundNotification);
 
-            //退款日志在申请时插入,接收通知时更新
+            // 退款日志在申请时插入,接收通知时更新
             var refundLog = refundLogService.lambdaQuery().eq(RefundLog::getOutRefundNo, refundNotification.getOutRefundNo()).one();
-            // 防止重复处理消息
+            if (refundLog == null) {
+                LOGGER.error("微信退款回调:未找到退款记录,outRefundNo={}", refundNotification.getOutRefundNo());
+                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "退款记录不存在"));
+            }
+            // 幂等快路径:已处理成功直接返回
             if (RefundLog.STATUS_退款成功.equals(refundLog.getStatus())) {
                 return ResponseEntity.status(HttpStatus.OK).build();
             }
@@ -709,28 +744,41 @@ public class WxPayServiceImpl implements WxPayService {
             DateTime dt = DateUtil.parse(refundNotification.getSuccessTime());
             LocalDateTime successTime = LocalDateTimeUtil.of(dt);
 
-            refundLogService.lambdaUpdate()
-                    .set(RefundLog::getRefundId, refundNotification.getRefundId())
-                    .set(RefundLog::getTransactionId, refundNotification.getTransactionId())
-                    .set(RefundLog::getUserReceivedAccount, refundNotification.getUserReceivedAccount())
-                    .set(RefundLog::getSuccessTime, successTime)
-                    .set(RefundLog::getStatus, refundNotification.getRefundStatus().name())
-                    .set(RefundLog::getTotal, refundNotification.getAmount().getTotal().intValue())
-                    .set(RefundLog::getRefund, refundNotification.getAmount().getRefund().intValue())
-                    .eq(RefundLog::getId, refundLog.getId()).update();
-
             if (RefundLog.STATUS_退款成功.equals(refundNotification.getRefundStatus().name())) {
+                // 资金流水必须在申请退款时已存在;检查放在门闩之前,避免门闩已置成功但后续失败造成不一致
+                var walletDetail = walletDetailService.getWalletDetailByOrderNo(refundNotification.getOutRefundNo(), WalletDetail.TYPE_退款);
+                if (walletDetail == null) {
+                    LOGGER.error("微信退款回调:未找到退款资金流水,outRefundNo={}", refundNotification.getOutRefundNo());
+                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "退款流水不存在"));
+                }
+
+                // 幂等门闩:条件更新(status <> 已成功),并发重复回调第二个事务更新 0 行直接跳过
+                boolean claimed = refundLogService.lambdaUpdate()
+                        .set(RefundLog::getRefundId, refundNotification.getRefundId())
+                        .set(RefundLog::getTransactionId, refundNotification.getTransactionId())
+                        .set(RefundLog::getUserReceivedAccount, refundNotification.getUserReceivedAccount())
+                        .set(RefundLog::getSuccessTime, successTime)
+                        .set(RefundLog::getStatus, refundNotification.getRefundStatus().name())
+                        .set(RefundLog::getTotal, refundNotification.getAmount().getTotal().intValue())
+                        .set(RefundLog::getRefund, refundNotification.getAmount().getRefund().intValue())
+                        .eq(RefundLog::getId, refundLog.getId())
+                        .ne(RefundLog::getStatus, RefundLog.STATUS_退款成功)
+                        .update();
+                if (!claimed) {
+                    return ResponseEntity.status(HttpStatus.OK).build();
+                }
+
                 // 冻结金额扣减此次(退款金额+优惠金额),优惠金额字段减去申请退款时的优惠金额
                 var account = accountService.getAccountByUserId(refundLog.getUserId());
-                accountService.lambdaUpdate().setSql("frozen_amount = (frozen_amount - %d) , discount_amount = (discount_amount - %d)"
-                                .formatted(refundNotification.getAmount().getRefund().intValue(), refundLog.getDiscountAmount()))
+                var refundAmount = refundNotification.getAmount().getRefund().intValue();
+                accountService.lambdaUpdate()
+                        .setSql("frozen_amount = (frozen_amount - {0}), discount_amount = (discount_amount - {1})",
+                                refundAmount, refundLog.getDiscountAmount())
                         .eq(Account::getUserId, refundLog.getUserId()).update();
 
                 // 更新资金流水
                 // 注意:此时 balance 已在 applyWxRefund 阶段清零,
                 // beforeBalance 应反映退款前余额(即退款金额),afterBalance 为 0
-                var walletDetail = walletDetailService.getWalletDetailByOrderNo(refundNotification.getOutRefundNo(), WalletDetail.TYPE_退款);
-                var refundAmount = refundNotification.getAmount().getRefund().intValue();
                 walletDetailService.lambdaUpdate()
                         .set(WalletDetail::getStatus, WalletDetail.STATUS_已确认)
                         .set(WalletDetail::getTransactionId, refundNotification.getTransactionId())
@@ -738,7 +786,6 @@ public class WxPayServiceImpl implements WxPayService {
                         .set(WalletDetail::getAmount, refundAmount)
                         .set(WalletDetail::getBeforeBalance, account.getBalance() + refundAmount)
                         .set(WalletDetail::getAfterBalance, account.getBalance())
-                        .set(WalletDetail::getTransactionTime, successTime)
                         .eq(WalletDetail::getId, walletDetail.getId()).update();
                 LOGGER.info("微信退款回调{}:业务处理结束", notifyRes[2]);
 
@@ -748,7 +795,7 @@ public class WxPayServiceImpl implements WxPayService {
                         .setFromStationId(stationId)
                         .setToStationId(stationId)
                         .setTradeNo(refundNotification.getTransactionId())
-                        .setAmount(refundNotification.getAmount().getRefund().intValue())
+                        .setAmount(refundAmount)
                         .setType(SplitRecord.TYPE_REFUND);
                 splitRecordService.save(refundSplit);