Преглед на файлове

Merge branch 'dev'

# Conflicts:
#	.gitignore
skyline преди 5 месеца
родител
ревизия
d8324f1686
променени са 59 файла, в които са добавени 3072 реда и са изтрити 222 реда
  1. 6 7
      admin-web/src/components/form/ExtSelect.vue
  2. 48 0
      admin-web/src/router/route.ts
  3. 4 3
      admin-web/src/utils/message.ts
  4. 14 0
      admin-web/src/utils/request.ts
  5. 61 3
      admin-web/src/views/admin/refund/index.vue
  6. 216 0
      admin-web/src/views/admin/whitelist/batch.vue
  7. 287 0
      admin-web/src/views/admin/whitelist/config.vue
  8. 176 0
      admin-web/src/views/admin/whitelist/dialog.vue
  9. 270 0
      admin-web/src/views/admin/whitelist/index.vue
  10. 0 1
      admin/src/main/java/com/kym/admin/AdminApplication.java
  11. 9 11
      admin/src/main/java/com/kym/admin/config/AsyncConfig.java
  12. 7 0
      admin/src/main/java/com/kym/admin/controller/CustomController.java
  13. 7 0
      admin/src/main/java/com/kym/admin/controller/FinanceController.java
  14. 116 0
      admin/src/main/java/com/kym/admin/controller/StationWhitelistController.java
  15. 49 3
      admin/src/main/java/com/kym/admin/jobs/ActivityDelayJob.java
  16. 36 17
      admin/src/main/java/com/kym/admin/jobs/InvoiceStatusJob.java
  17. 31 17
      admin/src/main/resources/application-dev.yml
  18. 16 7
      admin/src/main/resources/application-prod.yml
  19. 20 0
      admin/src/main/resources/static/index.html
  20. BIN
      admin/src/main/resources/static/logo.png
  21. 2 2
      common/pom.xml
  22. 33 0
      common/src/main/java/com/kym/common/config/JacksonConfig.java
  23. 4 0
      common/src/main/java/com/kym/common/constant/ResponseEnum.java
  24. 74 0
      common/src/main/java/com/kym/common/exception/ChargeException.java
  25. 69 0
      common/src/main/java/com/kym/common/exception/PlatformCommunicationException.java
  26. 65 4
      common/src/main/java/com/kym/common/handler/GlobalExceptionHandler.java
  27. 4 20
      common/src/main/java/com/kym/common/utils/CommUtil.java
  28. 1 1
      entity/pom.xml
  29. 39 0
      entity/src/main/java/com/kym/entity/admin/StationWhitelist.java
  30. 31 0
      entity/src/main/java/com/kym/entity/admin/StationWhitelistConfig.java
  31. 44 0
      entity/src/main/java/com/kym/entity/admin/StationWhitelistLog.java
  32. 18 0
      entity/src/main/java/com/kym/entity/admin/dto/BatchAddWhitelistDto.java
  33. 17 0
      entity/src/main/java/com/kym/entity/admin/queryParams/StationWhitelistQueryParam.java
  34. 24 0
      entity/src/main/java/com/kym/entity/admin/vo/StationWhitelistConfigVo.java
  35. 32 0
      entity/src/main/java/com/kym/entity/admin/vo/StationWhitelistVo.java
  36. 14 0
      entity/src/main/java/com/kym/entity/common/RedisKeys.java
  37. 31 0
      entity/src/main/java/com/kym/entity/miniapp/vo/RefundVo.java
  38. 8 0
      mapper/src/main/java/com/kym/mapper/admin/StationWhitelistConfigMapper.java
  39. 8 0
      mapper/src/main/java/com/kym/mapper/admin/StationWhitelistLogMapper.java
  40. 8 0
      mapper/src/main/java/com/kym/mapper/admin/StationWhitelistMapper.java
  41. 12 2
      mapper/src/main/resources/mappers/miniapp/RefundLogMapper.xml
  42. 26 3
      miniapp/src/main/java/com/kym/miniapp/jobs/StartChargeDelayJob.java
  43. 60 37
      miniapp/src/main/java/com/kym/miniapp/jobs/StopChargeDelayJob.java
  44. 31 17
      miniapp/src/main/resources/application-dev.yml
  45. 14 5
      miniapp/src/main/resources/application-prod.yml
  46. 23 13
      pom.xml
  47. 22 0
      service/src/main/java/com/kym/service/admin/StationWhitelistConfigService.java
  48. 19 0
      service/src/main/java/com/kym/service/admin/StationWhitelistLogService.java
  49. 33 0
      service/src/main/java/com/kym/service/admin/StationWhitelistService.java
  50. 3 1
      service/src/main/java/com/kym/service/admin/impl/StationServiceImpl.java
  51. 156 0
      service/src/main/java/com/kym/service/admin/impl/StationWhitelistConfigServiceImpl.java
  52. 100 0
      service/src/main/java/com/kym/service/admin/impl/StationWhitelistLogServiceImpl.java
  53. 307 0
      service/src/main/java/com/kym/service/admin/impl/StationWhitelistServiceImpl.java
  54. 243 44
      service/src/main/java/com/kym/service/cache/KymCache.java
  55. 2 0
      service/src/main/java/com/kym/service/miniapp/UserService.java
  56. 20 2
      service/src/main/java/com/kym/service/miniapp/impl/ChargeServiceImpl.java
  57. 11 0
      service/src/main/java/com/kym/service/miniapp/impl/UserServiceImpl.java
  58. 2 2
      service/src/main/java/com/kym/service/wechat/WxPayService.java
  59. 89 0
      service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

+ 6 - 7
admin-web/src/components/form/ExtSelect.vue

@@ -31,7 +31,7 @@ const emit = defineEmits(['update:modelValue', 'on-change']);
 
 const props = defineProps({
   modelValue: {
-    type: Number
+    type: [Number, String]
   },
   prefix: {
     type: String
@@ -85,7 +85,7 @@ const props = defineProps({
 
 const state = reactive({
   list: [] as Array<any>,
-  modelVal: null
+  modelVal: null as any
 })
 
 watch(() => props.modelValue, (val) => {
@@ -178,11 +178,10 @@ const loadData = () => {
 }
 
 const handleValueChange = (val: any) => {
-  nextTick(() => {
-    console.log("handleValueChange--->", val)
-    emit("update:modelValue", val);
-    emit("on-change", val);
-  })
+  console.log("handleValueChange--->", val)
+  emit("update:modelValue", val);
+  emit("on-change", val);
+  emit("change", val);
 }
 
 const handleValueClear = () => {

+ 48 - 0
admin-web/src/router/route.ts

@@ -379,6 +379,54 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                     perm:"invoice.list",
                 },
             },
+            {
+                path: '/whitelist',
+                name: 'adminWhitelist',
+                component: () => import('/@/layout/routerView/parent.vue'),
+                redirect: '/whitelist/config',
+                meta: {
+                    title: '白名单管理',
+                    isLink: '',
+                    isHide: false,
+                    isKeepAlive: true,
+                    isAffix: false,
+                    isIframe: false,
+                    icon: 'ele-Stamp',
+                    perm:"station.whitelist.list,station.whitelist.config.list",
+                },
+                children: [
+                    {
+                        path: '/whitelist/config',
+                        name: 'whitelistConfig',
+                        component: () => import('/@/views/admin/whitelist/config.vue'),
+                        meta: {
+                            title: '白名单配置',
+                            isLink: '',
+                            isHide: false,
+                            isKeepAlive: true,
+                            isAffix: false,
+                            isIframe: false,
+                            perm:"station.whitelist.config.list",
+                            icon: 'ele-Setting',
+                        },
+                    },
+                    {
+                        path: '/whitelist/list',
+                        name: 'whitelistList',
+                        component: () => import('/@/views/admin/whitelist/index.vue'),
+                        meta: {
+                            title: '白名单用户',
+                            isLink: '',
+                            isHide: false,
+                            isKeepAlive: true,
+                            isAffix: false,
+                            isIframe: false,
+                            perm:"station.whitelist.list",
+                            icon: 'ele-UserFilled',
+                        },
+                    },
+                ]
+            },
             {
                 path: '/org',
                 name: 'adminOrg',

+ 4 - 3
admin-web/src/utils/message.ts

@@ -22,10 +22,11 @@ export const Msg = {
             title,
             {
                 distinguishCancelAndClose: true,
-                confirmButtonText: options?.ok || '确认',
-                cancelButtonText: options?.cancel || '取消',
+                confirmButtonText: options?.confirmButtonText || options?.ok || '确认',
+                cancelButtonText: options?.cancelButtonText || options?.cancel || '取消',
                 type: type,
-                buttonSize:'default'
+                buttonSize:'default',
+                dangerouslyUseHTMLString: true
             }
         ).then((v) => {
             console.log(v)

+ 14 - 0
admin-web/src/utils/request.ts

@@ -167,6 +167,20 @@ export function $body(url: string, data = {}, showLoading = false) {
     })
 }
 
+export function $delete(url: string, data = {}, showLoading = false) {
+    //if(showLoading)Spin.show();
+    return new Promise((resolve, reject) => {
+        service.delete(url, {data: data})
+            .then(response => {
+                ////if(showLoading)Spin.hide();
+                resolve(response.data);
+            }, err => {
+                ////if(showLoading)Spin.hide();
+                reject(err)
+            })
+    })
+}
+
 export function $upload(url: string, formData = {}, showLoading = false) {
     //if(showLoading)Spin.show();
     return new Promise((resolve, reject) => {

+ 61 - 3
admin-web/src/views/admin/refund/index.vue

@@ -133,7 +133,22 @@
               <ext-d-label type="Refund.status" v-model="row[field.prop]"/>
             </template>
             <template v-else-if="'action'===field.prop">
-              <el-button v-if="row.status==='NEW'"  size="small" plain  type="warning" @click="onRefundClick(row)">退款</el-button>
+              <el-button 
+                v-if="row.status==='NEW' && !row.isOverdue"  
+                size="small" 
+                plain  
+                type="warning" 
+                @click="onRefundClick(row)">
+                退款
+              </el-button>
+              <el-button 
+                v-if="row.status==='NEW' && row.isOverdue"  
+                size="small" 
+                plain 
+                type="danger" 
+                @click="onManualRefundClick(row)">
+                线下退款
+              </el-button>
             </template>
             <template v-else>
               <div>{{ row[field.prop] }}</div>
@@ -190,6 +205,7 @@ const state = reactive({
       {label: '退款状态', prop: 'status', resizable: true, width: 120},
       {label: '申请时间', prop: 'createTime', sortable: 'custom', resizable: true, width: 160},
       {label: '退款成功时间', prop: 'successTime', sortable: 'custom', resizable: true, width: 160},
+      {label: '充值时间', prop: 'rechargeTime', sortable: 'custom', resizable: true, width: 160},
       {label: '原充值订单金额', prop: 'total', resizable: true, width: 140},
       {label: '退款申请金额', prop: 'refund', resizable: true, width: 120},
       {label: '优惠金额', prop: 'discountAmount', resizable: true, width: 90},
@@ -269,7 +285,17 @@ const onRefundClick = ( row: any) => {
   // if(row.status){
   //   return;
   // }
-  Msg.confirm("请确认是否执行退款操作?").then(()=>{
+  Msg.confirm(
+    `<div style="line-height: 2; font-size: 14px;">` +
+    `<div style="font-weight: bold; color: #409EFF; font-size: 15px; margin-bottom: 10px;">【退款确认】</div>` +
+    `<div style="margin: 8px 0;"><span style="color: #606266;">用户手机:</span><span style="font-weight: 500;">${row.mobilePhone}</span></div>` +
+    `<div style="margin: 8px 0;"><span style="color: #606266;">退款金额:</span><span style="font-weight: 500; color: #F56C6C;">${u.fmt.fmtMoney(row.refund)}</span></div>` +
+    `<div style="margin: 8px 0;"><span style="color: #606266;">申请时间:</span><span style="font-weight: 500;">${row.createTime ? row.createTime.replace('T', ' ') : '无'}</span></div>` +
+    `<div style="margin: 15px 0; padding: 10px; background-color: #ECF5FF; border-radius: 4px; border-left: 3px solid #409EFF;">` +
+    `<div style="color: #409EFF;">请确认是否执行微信退款操作?</div>` +
+    `</div>` +
+    `</div>`
+  ).then(()=>{
     $get(`/finance/customWxRefund/${row.refundLogId}`).then((res: any) => {
       Msg.message(`操作已提交`)
       loadData(true)
@@ -279,13 +305,45 @@ const onRefundClick = ( row: any) => {
   })
 };
 
+// 手动确认退款(线下退款)
+const onManualRefundClick = (row: any) => {
+  Msg.confirm(
+    `<div style="line-height: 2; font-size: 14px;">` +
+    `<div style="font-weight: bold; color: #E6A23C; font-size: 15px; margin-bottom: 10px;">【重要提醒】请确认已完成线下退款操作!</div>` +
+    `<div style="margin: 8px 0;"><span style="color: #606266;">用户手机:</span><span style="font-weight: 500;">${row.mobilePhone}</span></div>` +
+    `<div style="margin: 8px 0;"><span style="color: #606266;">退款金额:</span><span style="font-weight: 500; color: #F56C6C;">${u.fmt.fmtMoney(row.refund)}</span></div>` +
+    `<div style="margin: 8px 0;"><span style="color: #606266;">充值时间:</span><span style="font-weight: 500;">${row.rechargeTime ? row.rechargeTime.replace('T', ' ') : '无'}</span></div>` +
+    `<div style="margin: 15px 0; padding: 10px; background-color: #FDF6EC; border-radius: 4px; border-left: 3px solid #E6A23C;">` +
+    `<div style="color: #E6A23C; margin-bottom: 5px;">该订单已超过365天退款时限</div>` +
+    `<div style="color: #909399;">请确认已通过线下方式完成实际退款</div>` +
+    `</div>` +
+    `</div>`, 
+    '线下退款确认',
+    {
+      confirmButtonText: '确认已完成线下退款',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }
+  ).then(() => {
+    $get(`/finance/manualConfirmRefund/${row.refundLogId}`).then((res: any) => {
+      Msg.message(`线下退款确认成功`)
+      loadData(true)
+    }).catch(e => {
+      console.error(e)
+      Msg.message(`操作失败:${e.message}`, 'error')
+    })
+  }).catch(() => {
+    // 用户取消操作
+  });
+};
+
 
 const handleTableSelectionChange = (selection: any) => {
   console.log("handleTableSelectionChange>>", selection)
   // emit("on-check-change", selection)
 }
 
-const handleTableSortChange = (column, prop, order) => {
+const handleTableSortChange = (column: any, prop: any, order: any) => {
   console.log("handleTableSortChange>>", column, prop, order)
   // emit("on-sort-change", column)
 }

+ 216 - 0
admin-web/src/views/admin/whitelist/batch.vue

@@ -0,0 +1,216 @@
+<style scoped lang="scss">
+
+</style>
+<template>
+  <div class="system-dialog-container">
+    <el-drawer
+        title="批量添加白名单用户"
+        v-model="state.dialog.isShowDialog"
+        size="600px"
+        append-to-body
+        destroy-on-close
+        class="pd10"
+        :close-on-click-modal="false"
+        @close="onClose">
+      <el-form
+          :model="state.ruleForm"
+          :rules="state.rules"
+          label-position="top"
+          ref="formRef"
+          size="default"
+          label-width="100px"
+          class="mt5 pd10">
+        
+        <el-form-item label="选择站点" prop="stationId">
+          <ext-select
+              v-model="state.ruleForm.stationId"
+              placeholder="请选择站点"
+              url="station/listStation"
+              urlMethod="get"
+              data-key=""
+              label-key="stationName"
+              value-key="stationId"
+              clearable
+              class="w100"
+              @on-change="handleStationChange">
+          </ext-select>
+        </el-form-item>
+
+        <el-form-item label="搜索用户" prop="searchPhone">
+          <div class="search-container w100">
+            <el-input
+                v-model="state.searchPhone"
+                placeholder="请输入手机号搜索用户"
+                clearable
+                class="search-input">
+              <template #append>
+                <el-button @click="handleSearchUser" :loading="state.searchLoading">
+                  <SvgIcon name="ele-Search"/>
+                  搜索
+                </el-button>
+              </template>
+            </el-input>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="搜索结果">
+          <div class="search-result w100">
+            <el-table
+                :data="state.searchResult"
+                border
+                stripe
+                max-height="200"
+                v-loading="state.searchLoading"
+                @selection-change="handleSelectionChange">
+              <el-table-column type="selection" width="55" align="center"/>
+              <el-table-column prop="mobilePhone" label="手机号" width="130"/>
+              <el-table-column prop="nickname" label="昵称"/>
+            </el-table>
+            <div class="selected-info mt10" v-if="state.selectedUsers.length > 0">
+              <el-tag type="success">已选择 {{ state.selectedUsers.length }} 个用户</el-tag>
+            </div>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="备注" prop="remark">
+          <el-input
+              v-model="state.ruleForm.remark"
+              placeholder="请输入备注(可选)"
+              type="textarea"
+              :rows="3"
+              class="w100">
+          </el-input>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="onCancel" size="default">取 消</el-button>
+          <el-button :loading="state.btnLoading" type="primary" @click="onSubmit" size="default" :disabled="state.selectedUsers.length === 0">
+            批量添加
+          </el-button>
+        </div>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts" name="BatchAddWhitelistDialog">
+import { reactive, ref } from 'vue';
+import { Msg } from "/@/utils/message";
+import { $body, $get } from "/@/utils/request";
+import type { FormInstance, FormRules } from 'element-plus';
+import ExtSelect from "/@/components/form/ExtSelect.vue";
+
+const emit = defineEmits(['refresh']);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: {
+    stationId: '',
+    remark: ''
+  },
+  searchPhone: '',
+  searchLoading: false,
+  searchResult: [] as Array<any>,
+  selectedUsers: [] as Array<any>,
+  btnLoading: false,
+  dialog: {
+    isShowDialog: false
+  },
+  rules: {
+    stationId: [{ required: true, message: '请选择站点', trigger: 'change' }]
+  }
+})
+
+const state = reactive(initState());
+
+const open = () => {
+  state.dialog.isShowDialog = true;
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => {
+  onClose();
+};
+
+const handleStationChange = () => {
+  state.selectedUsers = [];
+  state.searchResult = [];
+};
+
+const handleSearchUser = () => {
+  if (!state.searchPhone || state.searchPhone.length < 4) {
+    Msg.message('请输入至少4位手机号', 'warning');
+    return;
+  }
+  state.searchLoading = true;
+  $get(`/custom/simpleList`, { mobilePhone: state.searchPhone, pageNum: 1, pageSize: 50 }).then((res: any) => {
+    let { list } = res;
+    state.searchResult = list || [];
+    state.searchLoading = false;
+  }).catch(() => {
+    state.searchResult = [];
+    state.searchLoading = false;
+  });
+};
+
+const handleSelectionChange = (selection: any) => {
+  state.selectedUsers = selection;
+};
+
+const onSubmit = () => {
+  formRef.value.validate((valid) => {
+    if (valid) {
+      if (state.selectedUsers.length === 0) {
+        Msg.message('请选择要添加的用户', 'warning');
+        return;
+      }
+      state.btnLoading = true;
+      const userIds = state.selectedUsers.map(u => u.id);
+      $body(`/station-whitelist/batchAdd`, {
+        stationId: state.ruleForm.stationId,
+        userIds: userIds,
+        remark: state.ruleForm.remark
+      }).then(() => {
+        Msg.message(`成功添加 ${userIds.length} 个用户`, 'success');
+        emit('refresh');
+        state.btnLoading = false;
+        onClose();
+      }).catch(() => {
+        state.btnLoading = false;
+      })
+    } else {
+      Msg.message('表单校验失败', 'error');
+    }
+  })
+};
+
+defineExpose({
+  open
+});
+</script>
+
+<style scoped lang="scss">
+.search-container {
+  display: flex;
+  
+  .search-input {
+    width: 100%;
+  }
+}
+
+.search-result {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  padding: 10px;
+}
+
+.selected-info {
+  text-align: right;
+}
+</style>

+ 287 - 0
admin-web/src/views/admin/whitelist/config.vue

@@ -0,0 +1,287 @@
+<style scoped lang="scss">
+.system-container {
+
+  :deep(.el-card__body) {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    flex: 1;
+    overflow: auto;
+
+    .el-table {
+      flex: 1;
+    }
+
+  }
+}
+
+.page-content {
+  margin-bottom: 20px;
+}
+
+.page-pager {
+  background-color: #fff;
+  height: 24px;
+}
+
+.tip-card {
+  margin-bottom: 15px;
+  
+  :deep(.el-card__body) {
+    padding: 12px 20px;
+  }
+}
+</style>
+<template>
+  <div class="system-container layout-padding">
+    <el-card shadow="hover" class="layout-padding-auto">
+
+      <el-card class="tip-card" shadow="never">
+        <el-alert
+            title="白名单功能说明"
+            type="info"
+            :closable="false"
+            show-icon>
+          <template #default>
+            <p>1. 启用站点白名单后,只有白名单中的用户才能在该站点启动充电</p>
+            <p>2. 适用于内部专用站点或需要限制使用人数的场景</p>
+            <p>3. 白名单启用后,非白名单用户尝试充电将被拒绝</p>
+          </template>
+        </el-alert>
+      </el-card>
+
+      <el-form
+          :model="state.formQuery"
+          ref="queryRef"
+          size="default" label-width="0px" class="mt5 mb5">
+
+        <ext-select
+            v-model="state.formQuery.stationId"
+            placeholder="选择站点"
+            clearable
+            url="station/listStation"
+            urlMethod="get"
+            data-key=""
+            label-key="stationName"
+            value-key="stationId"
+            @on-change="loadData(true)"
+            class="wd200 mr10">
+        </ext-select>
+
+        <el-button class="ml10" plain size="default" type="success" @click="loadData(true)">
+          <SvgIcon name="ele-Search"/>
+          查询
+        </el-button>
+
+        <el-button v-auth="'station.whitelist.config.modify'" size="default" plain type="primary" class="ml10" @click="onAddConfig">
+          <SvgIcon name="ele-Plus"/>
+          添加配置
+        </el-button>
+      </el-form>
+
+      <el-table
+          border
+          stripe
+          :height="state.tableData.height"
+          highlight-current-row
+          current-row-key="id"
+          row-key="id"
+          :data="state.tableData.data"
+          v-loading="state.tableData.loading">
+        <template #empty>
+          <el-empty></el-empty>
+        </template>
+        <el-table-column
+            v-for="field in state.tableData.columns"
+            :key="field.prop"
+            :label="field.label"
+            :column-key="field.prop"
+            :width="field.width"
+            :min-width="field.minWidth"
+            :fixed="field.fixed"
+            :sortable="field.sortable"
+            :show-overflow-tooltip="!field.fixed && field.width > 150">
+          <template #default="{ row }">
+            <template v-if="'enabled' === field.prop">
+              <el-switch
+                  v-model="row.enabled"
+                  :active-value="1"
+                  :inactive-value="0"
+                  active-text="启用"
+                  inactive-text="禁用"
+                  @change="handleStatusChange(row)"
+                  v-auth="'station.whitelist.config.modify'">
+              </el-switch>
+            </template>
+            <template v-else-if="'action' === field.prop">
+              <el-button v-auth="'station.whitelist.list'" size="small" plain type="primary" @click="handleManageWhitelist(row)">
+                管理白名单
+              </el-button>
+              <el-button v-auth="'station.whitelist.config.delete'" size="small" plain type="danger" @click="onRowDel(row)">
+                删除
+              </el-button>
+            </template>
+            <template v-else>
+              <div>{{ row[field.prop] }}</div>
+            </template>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog
+        v-model="state.dialog.isShowDialog"
+        title="添加白名单配置"
+        width="500px"
+        append-to-body
+        destroy-on-close>
+      <el-form label-width="100px" class="mt20">
+        <el-form-item label="选择站点" required>
+          <ext-select
+              v-model="state.dialog.stationId"
+              placeholder="请选择站点"
+              url="station/listStation"
+              urlMethod="get"
+              data-key=""
+              label-key="stationName"
+              value-key="stationId"
+              clearable
+              class="w100">
+          </ext-select>
+        </el-form-item>
+        <el-form-item label="初始状态">
+          <el-tag type="success">启用</el-tag>
+          <span class="ml10 text-gray">添加后默认启用白名单功能</span>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="onClose">取消</el-button>
+          <el-button type="primary" @click="onSubmit">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="StationWhitelistConfigList">
+import { reactive, onMounted, ref, nextTick } from 'vue';
+import { $body, $get, $put, $delete } from "/@/utils/request";
+import { Msg } from "/@/utils/message";
+import ExtSelect from "/@/components/form/ExtSelect.vue";
+import { useRouter } from 'vue-router';
+
+const router = useRouter();
+const queryRef = ref();
+
+const state = reactive({
+  formQuery: {
+    stationId: ''
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false,
+    columns: [
+      {label: '站点ID', prop: 'stationId', width: 160, resizable: true},
+      {label: '站点名称', prop: 'stationName', width: 200, resizable: true},
+      {label: '白名单状态', prop: 'enabled', width: 180, align: 'center'},
+      {label: '创建时间', prop: 'createTime', width: 180, resizable: true},
+      {label: '更新时间', prop: 'updateTime', width: 180, resizable: true},
+      {
+        label: '操作', prop: 'action', width: 150, align: 'center', fixed: 'right',
+      }
+    ],
+  },
+  dialog: {
+    isShowDialog: false,
+    stationId: ''
+  }
+})
+
+onMounted(() => {
+  loadData();
+
+  nextTick(() => {
+    let bodyHeight = document.body.clientHeight;
+    let queryHeight = queryRef.value.$el.clientHeight;
+    state.tableData.height = bodyHeight - queryHeight - 380
+  })
+});
+
+const loadData = (refresh: boolean = false) => {
+  state.tableData.loading = true;
+  $get(`/station-whitelist/config/list`, state.formQuery).then((res: any) => {
+    state.tableData.data = res || [];
+    state.tableData.loading = false;
+  }).catch(e => {
+    console.error(e)
+    state.tableData.loading = false;
+  })
+};
+
+const handleStatusChange = (row: any) => {
+  const statusText = row.enabled === 1 ? '启用' : '禁用';
+  $body(`/station-whitelist/config/update`, {
+    stationId: row.stationId,
+    enabled: row.enabled
+  }).then(() => {
+    Msg.message(`${statusText}成功`, 'success');
+  }).catch(() => {
+    Msg.message(`${statusText}失败`, 'error');
+    row.enabled = row.enabled === 1 ? 0 : 1;
+  });
+};
+
+const handleManageWhitelist = (row: any) => {
+  router.push({
+    path: '/whitelist/list',
+    query: { stationId: row.stationId }
+  });
+};
+
+const onAddConfig = () => {
+  state.dialog.stationId = '';
+  state.dialog.isShowDialog = true;
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  state.dialog.stationId = '';
+};
+
+const onSubmit = () => {
+  if (!state.dialog.stationId) {
+    Msg.message('请选择站点', 'warning');
+    return;
+  }
+  // 检查是否已存在配置
+  const exists = state.tableData.data.some((item: any) => item.stationId === state.dialog.stationId);
+  if (exists) {
+    Msg.message('该站点已存在白名单配置', 'warning');
+    return;
+  }
+  
+  $body(`/station-whitelist/config/update`, {
+    stationId: state.dialog.stationId,
+    enabled: 1
+  }).then(() => {
+    Msg.message('添加成功', 'success');
+    loadData(true);
+    onClose();
+  }).catch(() => {
+    Msg.message('添加失败', 'error');
+  });
+};
+
+const onRowDel = (row: any) => {
+  Msg.confirm(`确定要删除站点『${row.stationName}』的白名单配置吗?删除后将清空该站点的所有白名单用户。`).then(() => {
+    $delete(`/station-whitelist/config/delete/${row.stationId}`).then(() => {
+      Msg.message("删除成功", 'success');
+      loadData(true);
+    }).catch(() => {
+      Msg.message("删除失败", 'error');
+    })
+  });
+};
+</script>

+ 176 - 0
admin-web/src/views/admin/whitelist/dialog.vue

@@ -0,0 +1,176 @@
+<style scoped lang="scss">
+
+</style>
+<template>
+  <div class="system-dialog-container">
+    <el-drawer
+        :title="state.dialog.title"
+        v-model="state.dialog.isShowDialog"
+        size="500px"
+        append-to-body
+        destroy-on-close
+        class="pd10"
+        :close-on-click-modal="false"
+        @close="onClose">
+      <el-form
+          :model="state.ruleForm"
+          :rules="state.rules"
+          label-position="top"
+          ref="formRef"
+          size="default"
+          label-width="100px"
+          class="mt5 pd10">
+        
+        <el-form-item label="选择站点" prop="stationId">
+          <ext-select
+              v-model="state.ruleForm.stationId"
+              placeholder="请选择站点"
+              url="station/listStation"
+              urlMethod="get"
+              data-key=""
+              label-key="stationName"
+              value-key="stationId"
+              clearable
+              class="w100"
+              @on-change="handleStationChange">
+          </ext-select>
+        </el-form-item>
+
+        <el-form-item label="选择用户" prop="userId">
+          <el-select-v2
+              v-model="state.ruleForm.userId"
+              :options="state.userList"
+              filterable
+              remote
+              :remote-method="handleUserSearch"
+              placeholder="请输入手机号搜索用户"
+              class="w100"
+              clearable>
+            <template #default="{ item }">
+              <span>{{ item.label }}</span>
+            </template>
+          </el-select-v2>
+        </el-form-item>
+
+        <el-form-item label="备注" prop="remark">
+          <el-input
+              v-model="state.ruleForm.remark"
+              placeholder="请输入备注"
+              type="textarea"
+              :rows="3"
+              class="w100">
+          </el-input>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="onCancel" size="default">取 消</el-button>
+          <el-button :loading="state.btnLoading" type="primary" @click="onSubmit" size="default">{{ state.dialog.submitTxt }}</el-button>
+        </div>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts" name="WhitelistDialog">
+import { reactive, ref } from 'vue';
+import { Msg } from "/@/utils/message";
+import { $body, $get } from "/@/utils/request";
+import u from '/@/utils/u';
+import type { FormInstance, FormRules } from 'element-plus';
+import ExtSelect from "/@/components/form/ExtSelect.vue";
+
+const emit = defineEmits(['refresh']);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: {
+    id: 0,
+    stationId: null as string | null,
+    userId: null as number | null,
+    remark: ''
+  },
+  btnLoading: false,
+  dialog: {
+    isShowDialog: false,
+    type: '',
+    title: '',
+    submitTxt: '',
+  },
+  rules: {
+    stationId: [{ required: true, message: '请选择站点', trigger: 'change' }],
+    userId: [{ required: true, message: '请选择用户', trigger: 'change' }]
+  },
+  userList: [] as Array<{ value: number, label: string }>
+})
+
+const state = reactive(initState());
+
+const open = (action: string = 'add', row: any) => {
+  state.dialog.title = u.dialog.actions[action].title + "『白名单用户』"
+  state.dialog.submitTxt = u.dialog.actions[action].btn + "『白名单用户』"
+  state.dialog.isShowDialog = true;
+  state.dialog.type = action;
+  
+  if (action !== 'add' && row) {
+    state.ruleForm.id = row.id;
+    state.ruleForm.stationId = row.stationId;
+    state.ruleForm.userId = row.userId;
+    state.ruleForm.remark = row.remark;
+    if (row.userPhone) {
+      state.userList = [{ value: row.userId, label: `${row.userPhone} (${row.userNickname || ''})` }];
+    }
+  }
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => {
+  onClose();
+};
+
+const onSubmit = () => {
+  formRef.value.validate((valid) => {
+    if (valid) {
+      state.btnLoading = true;
+      $body(`/station-whitelist/add`, state.ruleForm).then(() => {
+        Msg.message('操作成功', 'success');
+        emit('refresh');
+        state.btnLoading = false;
+        onClose();
+      }).catch(() => {
+        state.btnLoading = false;
+      })
+    } else {
+      Msg.message('表单校验失败', 'error');
+    }
+  })
+};
+
+const handleUserSearch = (query: string) => {
+  if (!query || query.length < 4) {
+    return;
+  }
+  $get(`/custom/simpleList`, { mobilePhone: query, pageNum: 1, pageSize: 20 }).then((res: any) => {
+    let { list } = res;
+    state.userList = list.map((k: any) => {
+      return { value: k.id, label: `${k.mobilePhone} (${k.nickname || '未设置昵称'})` }
+    });
+  }).catch(() => {
+    state.userList = [];
+  });
+};
+
+const handleStationChange = (val: any) => {
+  state.ruleForm.stationId = val;
+  formRef.value?.clearValidate('stationId');
+};
+
+defineExpose({
+  open
+});
+</script>

+ 270 - 0
admin-web/src/views/admin/whitelist/index.vue

@@ -0,0 +1,270 @@
+<style scoped lang="scss">
+.system-container {
+
+  :deep(.el-card__body) {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    flex: 1;
+    overflow: auto;
+
+    .el-table {
+      flex: 1;
+    }
+
+  }
+}
+
+.page-content {
+  margin-bottom: 20px;
+}
+
+.page-pager {
+  background-color: #fff;
+  height: 24px;
+}
+</style>
+<template>
+  <div class="system-container layout-padding">
+    <el-card shadow="hover" class="layout-padding-auto">
+
+      <el-form
+          :model="state.formQuery"
+          ref="queryRef"
+          size="default" label-width="0px" class="mt5 mb5">
+
+        <ext-select
+            v-model="state.formQuery.stationId"
+            placeholder="选择站点"
+            clearable
+            url="station/listStation"
+            urlMethod="get"
+            data-key=""
+            label-key="stationName"
+            value-key="stationId"
+            @on-change="loadData(true)"
+            class="wd200 mr10">
+        </ext-select>
+
+        <el-input
+            v-model="state.formQuery.userPhone"
+            placeholder="用户手机号"
+            clearable
+            @blur="loadData(true)"
+            class="wd150 mr10">
+        </el-input>
+
+        <el-input
+            v-model="state.formQuery.userNickname"
+            placeholder="用户昵称"
+            clearable
+            @blur="loadData(true)"
+            class="wd150 mr10">
+        </el-input>
+
+        <el-select
+            v-model="state.formQuery.status"
+            placeholder="状态"
+            clearable
+            @change="loadData(true)"
+            class="wd120 mr10">
+          <el-option label="启用" :value="1"/>
+          <el-option label="禁用" :value="0"/>
+        </el-select>
+
+        <el-button class="ml10" plain size="default" type="success" @click="loadData(true)">
+          <SvgIcon name="ele-Search"/>
+          查询
+        </el-button>
+
+        <el-button v-auth="'station.whitelist.add'" size="default" plain type="primary" class="ml10" @click="onRowClick('add', null)">
+          <SvgIcon name="ele-Plus"/>
+          添加用户
+        </el-button>
+
+        <el-button v-auth="'station.whitelist.add'" size="default" plain type="warning" class="ml10" @click="onBatchAdd">
+          <SvgIcon name="ele-DocumentAdd"/>
+          批量添加
+        </el-button>
+
+        <el-button v-auth="'station.whitelist.delete'" size="default" plain type="danger" class="ml10" @click="onBatchRemove" :disabled="state.selectedIds.length === 0">
+          <SvgIcon name="ele-Delete"/>
+          批量移除
+        </el-button>
+      </el-form>
+
+      <el-table
+          border
+          stripe
+          :height="state.tableData.height"
+          highlight-current-row
+          current-row-key="id"
+          row-key="id"
+          :data="state.tableData.data"
+          v-loading="state.tableData.loading"
+          @selection-change="handleTableSelectionChange">
+        <template #empty>
+          <el-empty></el-empty>
+        </template>
+        <el-table-column type="selection" width="55" align="center"/>
+        <el-table-column
+            v-for="field in state.tableData.columns"
+            :key="field.prop"
+            :label="field.label"
+            :column-key="field.prop"
+            :width="field.width"
+            :min-width="field.minWidth"
+            :fixed="field.fixed"
+            :sortable="field.sortable"
+            :show-overflow-tooltip="!field.fixed && field.width > 150">
+          <template #default="{ row }">
+            <template v-if="'status' === field.prop">
+              <el-tag :type="row[field.prop] === 1 ? 'success' : 'danger'">
+                {{ row[field.prop] === 1 ? '启用' : '禁用' }}
+              </el-tag>
+            </template>
+            <template v-else-if="'action' === field.prop">
+              <el-button v-auth="'station.whitelist.modify'" size="small" plain type="warning" @click="onRowClick('edit', row)">编辑</el-button>
+              <el-button v-auth="'station.whitelist.delete'" size="small" plain type="danger" @click="onRowDel(row)">移除</el-button>
+            </template>
+            <template v-else>
+              <div>{{ row[field.prop] }}</div>
+            </template>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <ext-page class="page-pager" v-model:value="state.pageQuery" @change="loadData(false)"/>
+    </el-card>
+  </div>
+  <WhitelistDialog ref="whitelistDialogRef" @refresh="loadData(true)"/>
+  <BatchAddDialog ref="batchAddDialogRef" @refresh="loadData(true)"/>
+</template>
+
+<script setup lang="ts" name="StationWhitelistList">
+import {defineAsyncComponent, reactive, onMounted, onBeforeMount, ref, nextTick, onBeforeUnmount} from 'vue';
+import {$body, $get, $delete} from "/@/utils/request";
+import {Msg} from "/@/utils/message";
+import ExtPage from '/@/components/form/ExtPage.vue'
+import mittBus from '/@/utils/mitt';
+import ExtSelect from "/@/components/form/ExtSelect.vue";
+import {useRoute} from "vue-router";
+
+const route = useRoute();
+const WhitelistDialog = defineAsyncComponent(() => import("/@/views/admin/whitelist/dialog.vue"));
+const BatchAddDialog = defineAsyncComponent(() => import("/@/views/admin/whitelist/batch.vue"));
+
+const queryRef = ref();
+const whitelistDialogRef = ref();
+const batchAddDialogRef = ref();
+
+const state = reactive({
+  formQuery: {
+    stationId: '',
+    userPhone: '',
+    userNickname: '',
+    status: null as number | null
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false,
+    columns: [
+      {label: '站点名称', prop: 'stationName', width: 180, resizable: true},
+      {label: '用户手机号', prop: 'userPhone', width: 140, resizable: true},
+      {label: '用户昵称', prop: 'userNickname', width: 150, resizable: true},
+      {label: '状态', prop: 'status', width: 100, align: 'center'},
+      {label: '备注', prop: 'remark', minWidth: 200, resizable: true},
+      {label: '创建时间', prop: 'createTime', width: 180, resizable: true},
+      {
+        label: '操作', prop: 'action', width: 160, align: 'center', fixed: 'right',
+      }
+    ],
+  },
+  selectedIds: [] as number[]
+})
+
+onBeforeMount(() => {
+  const stationId = route.query.stationId as string;
+  if (stationId) {
+    state.formQuery.stationId = stationId;
+  }
+})
+
+onMounted(() => {
+  loadData();
+
+  nextTick(() => {
+    let bodyHeight = document.body.clientHeight;
+    let queryHeight = queryRef.value.$el.clientHeight;
+    state.tableData.height = bodyHeight - queryHeight - 320
+  })
+
+  mittBus.on("stationWhitelist.refresh", () => {
+    loadData();
+  })
+});
+
+onBeforeUnmount(() => {
+  mittBus.off("stationWhitelist.refresh")
+})
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) {
+    state.pageQuery.pageNum = 1;
+  }
+  state.tableData.loading = true;
+  $get(`/station-whitelist/list`, { ...state.formQuery, ...state.pageQuery }).then((res: any) => {
+    let { list, total } = res;
+    state.tableData.data = list;
+    state.pageQuery.total = total;
+    state.tableData.loading = false;
+  }).catch(e => {
+    console.error(e)
+    state.tableData.loading = false;
+  })
+};
+
+const onRowClick = (type: string, row: any) => {
+  whitelistDialogRef.value.open(type, row);
+};
+
+const onBatchAdd = () => {
+  batchAddDialogRef.value.open();
+};
+
+const onBatchRemove = () => {
+  if (state.selectedIds.length === 0) {
+    Msg.message('请选择要移除的用户', 'warning');
+    return;
+  }
+  Msg.confirm(`此操作将移除选中的 ${state.selectedIds.length} 个用户,是否继续?`).then(() => {
+    $delete(`/station-whitelist/batchRemove`, state.selectedIds).then(() => {
+      Msg.message("移除成功", 'success');
+      loadData(true);
+    }).catch(() => {
+      Msg.message("移除失败", 'error');
+    })
+  });
+};
+
+const onRowDel = (row: any) => {
+  Msg.confirm(`此操作将移除用户『${row.userNickname || row.userPhone}』,是否继续?`).then(() => {
+    $delete(`/station-whitelist/remove/${row.id}`).then(() => {
+      Msg.message("移除成功", 'success');
+      loadData(true);
+    }).catch(() => {
+      Msg.message("移除失败", 'error');
+    })
+  });
+};
+
+const handleTableSelectionChange = (selection: any) => {
+  state.selectedIds = selection.map((item: any) => item.id);
+}
+</script>

+ 0 - 1
admin/src/main/java/com/kym/admin/AdminApplication.java

@@ -5,7 +5,6 @@ import org.mybatis.spring.annotation.MapperScan;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.context.annotation.ComponentScan;
-import org.springframework.context.annotation.FilterType;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.stereotype.Controller;

+ 9 - 11
admin/src/main/java/com/kym/admin/config/AsyncConfig.java

@@ -2,25 +2,23 @@ package com.kym.admin.config;
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.scheduling.annotation.EnableAsync;
 
 import java.util.concurrent.Executor;
-import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.Executors;
 
 
 @Configuration
+@EnableAsync  // 启用异步支持
 public class AsyncConfig {
 
+    /**
+     * Java 21 虚拟线程异步执行器
+     * 替代传统的ThreadPoolTaskExecutor,提供更好的并发性能
+     */
     @Bean("AsyncExecutor")
     public Executor customAsyncExecutor() {
-        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
-        executor.setCorePoolSize(3);
-        executor.setMaxPoolSize(20);
-        executor.setQueueCapacity(1<<10);
-        executor.setKeepAliveSeconds(60);
-        executor.setThreadNamePrefix("async-exec-");
-        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
-        executor.initialize();
-        return executor;
+        // 使用虚拟线程池,每个任务都会获得一个轻量级的虚拟线程
+        return Executors.newVirtualThreadPerTaskExecutor();
     }
 }

+ 7 - 0
admin/src/main/java/com/kym/admin/controller/CustomController.java

@@ -1,6 +1,7 @@
 package com.kym.admin.controller;
 
 import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
 import com.kym.common.R;
 import com.kym.common.annotation.SysLog;
 import com.kym.entity.admin.queryParams.CommonQueryParam;
@@ -63,4 +64,10 @@ public class CustomController {
         return R.success(payLogService.listRecharge(params));
     }
 
+    @GetMapping("/simpleList")
+    @SaCheckPermission(value = {"account.list", "station.whitelist.add"}, mode = SaMode.OR)
+    @SysLog("查询用户列表(简略)")
+    public R<?> listSimple(@ModelAttribute CommonQueryParam params) {
+        return R.success(customUserService.listSimpleUser(params));
+    }
 }

+ 7 - 0
admin/src/main/java/com/kym/admin/controller/FinanceController.java

@@ -74,6 +74,13 @@ public class FinanceController {
         return R.success();
     }
 
+    @SysLog("手动确认退款(线下退款)")
+    @GetMapping("/manualConfirmRefund/{refundLogId}")
+    R<?> manualConfirmRefund(@PathVariable("refundLogId") long refundLogId) {
+        wxPayService.manualConfirmRefund(refundLogId);
+        return R.success();
+    }
+
     @GetMapping("/getUserTitle/{applyId}")
     Object getUserTitle(@PathVariable String applyId) {
         return wxPayService.userTitle(applyId);

+ 116 - 0
admin/src/main/java/com/kym/admin/controller/StationWhitelistController.java

@@ -0,0 +1,116 @@
+package com.kym.admin.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.kym.common.R;
+import com.kym.common.annotation.SysLog;
+import com.kym.common.controller.IController;
+import com.kym.entity.admin.StationWhitelist;
+import com.kym.entity.admin.StationWhitelistConfig;
+import com.kym.entity.admin.dto.BatchAddWhitelistDto;
+import com.kym.entity.admin.queryParams.StationWhitelistQueryParam;
+import com.kym.entity.admin.vo.StationWhitelistConfigVo;
+import com.kym.service.admin.StationWhitelistConfigService;
+import com.kym.service.admin.StationWhitelistService;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/station-whitelist")
+public class StationWhitelistController extends IController {
+
+    private final StationWhitelistService stationWhitelistService;
+    private final StationWhitelistConfigService stationWhitelistConfigService;
+
+    public StationWhitelistController(StationWhitelistService stationWhitelistService,
+                                       StationWhitelistConfigService stationWhitelistConfigService) {
+        this.stationWhitelistService = stationWhitelistService;
+        this.stationWhitelistConfigService = stationWhitelistConfigService;
+    }
+
+    @GetMapping("/list")
+    @SaCheckPermission("station.whitelist.list")
+    @SysLog("站点白名单-查询列表")
+    public R<?> list(@ModelAttribute StationWhitelistQueryParam param) {
+        return R.success(stationWhitelistService.listWhitelist(param));
+    }
+
+    @PostMapping("/add")
+    @SaCheckPermission("station.whitelist.add")
+    @SysLog(value = "站点白名单-添加用户", ignoreParams = true)
+    public R<?> add(@RequestBody StationWhitelist whitelist) {
+        stationWhitelistService.addWhitelist(whitelist);
+        return R.success();
+    }
+
+    @PostMapping("/batchAdd")
+    @SaCheckPermission("station.whitelist.add")
+    @SysLog(value = "站点白名单-批量添加用户", ignoreParams = true)
+    public R<?> batchAdd(@RequestBody BatchAddWhitelistDto dto) {
+        stationWhitelistService.batchAddWhitelist(dto);
+        return R.success();
+    }
+
+    @DeleteMapping("/remove/{id}")
+    @SaCheckPermission("station.whitelist.delete")
+    @SysLog("站点白名单-移除用户")
+    public R<?> remove(@PathVariable Long id) {
+        stationWhitelistService.removeWhitelist(id);
+        return R.success();
+    }
+
+    @DeleteMapping("/batchRemove")
+    @SaCheckPermission("station.whitelist.delete")
+    @SysLog("站点白名单-批量移除用户")
+    public R<?> batchRemove(@RequestBody List<Long> ids) {
+        stationWhitelistService.batchRemoveWhitelist(ids);
+        return R.success();
+    }
+
+    @PutMapping("/status/{id}")
+    @SaCheckPermission("station.whitelist.modify")
+    @SysLog("站点白名单-更新状态")
+    public R<?> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
+        stationWhitelistService.updateStatus(id, status);
+        return R.success();
+    }
+
+    @GetMapping("/config/list")
+    @SaCheckPermission("station.whitelist.config.list")
+    @SysLog("站点白名单配置-查询列表")
+    public R<?> listConfig() {
+        return R.success(stationWhitelistConfigService.listConfig());
+    }
+
+    @GetMapping("/config/station/{stationId}")
+    @SaCheckPermission("station.whitelist.config.list")
+    @SysLog("站点白名单配置-查询站点配置")
+    public R<?> getConfigByStationId(@PathVariable String stationId) {
+        StationWhitelistConfig config = stationWhitelistConfigService.getConfigByStationId(stationId);
+        return R.success(config);
+    }
+
+    @PostMapping("/config/update")
+    @SaCheckPermission("station.whitelist.config.modify")
+    @SysLog("站点白名单配置-更新配置")
+    public R<?> updateConfig(@RequestBody StationWhitelistConfig config) {
+        stationWhitelistConfigService.updateConfig(config.getStationId(), config.getEnabled());
+        return R.success();
+    }
+
+    @DeleteMapping("/config/delete/{stationId}")
+    @SaCheckPermission("station.whitelist.config.delete")
+    @SysLog("站点白名单配置-删除配置")
+    public R<?> deleteConfig(@PathVariable String stationId) {
+        stationWhitelistConfigService.deleteConfig(stationId);
+        return R.success();
+    }
+
+    @PostMapping("/refreshCache/{stationId}")
+    @SaCheckPermission("station.whitelist.modify")
+    @SysLog("站点白名单-刷新缓存")
+    public R<?> refreshCache(@PathVariable String stationId) {
+        stationWhitelistService.refreshCache(stationId);
+        return R.success();
+    }
+}

+ 49 - 3
admin/src/main/java/com/kym/admin/jobs/ActivityDelayJob.java

@@ -18,6 +18,7 @@ import java.util.Comparator;
 import java.util.concurrent.DelayQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 
 /**
@@ -36,6 +37,16 @@ public class ActivityDelayJob implements DelayService<DelayActivity> {
      */
     private final static DelayQueue<DelayedItem<DelayActivity>> DELAY_QUEUE = new DelayQueue<>();
 
+    /**
+     * 队列最大容量
+     */
+    private static final int MAX_QUEUE_SIZE = 10000;
+
+    /**
+     * 控制线程运行状态的标志位
+     */
+    private volatile boolean running = true;
+
     private final ActivityService activityService;
     private final ActivityStationService activityStationService;
     private final RechargeRightsService rechargeRightsService;
@@ -90,9 +101,12 @@ public class ActivityDelayJob implements DelayService<DelayActivity> {
             ThreadLocal<Long> threadLocal = new ThreadLocal<>();
             log.info("活动延迟启闭处理线程:{}", Thread.currentThread().getName());
             DelayedItem<DelayActivity> delayedItem;
-            while (true) {
+            while (running) {
                 try {
                     delayedItem = DELAY_QUEUE.take();
+                    if (!running) {
+                        break;
+                    }
                     var delayActivity = delayedItem.data;
                     threadLocal.set(delayActivity.getId());
                     if (delayActivity.getType().equals(DelayActivity.TYPE_启动)) {
@@ -142,25 +156,36 @@ public class ActivityDelayJob implements DelayService<DelayActivity> {
                     Thread.sleep(100);
                 } catch (Exception e) {
                     if (e instanceof InterruptedException) {
-                        log.error("活动到期停止队列take异常", e);
+                        log.info("活动延迟任务线程被中断,准备退出");
+                        Thread.currentThread().interrupt();
+                        break;
                     } else {
-                        log.info("活动到期停止,主活动id:{}", threadLocal.get(), e);
+                        log.error("活动到期停止处理异常,主活动id:{}", threadLocal.get(), e);
                     }
                 } finally {
                     threadLocal.remove();
                 }
             }
+            log.info("活动延迟启闭处理线程已退出");
         });
 
     }
 
     @Override
     public boolean addToDelayQueue(DelayedItem<DelayActivity> delayedItem) {
+        if (DELAY_QUEUE.size() >= MAX_QUEUE_SIZE) {
+            log.error("活动延迟队列已满,拒绝添加任务,当前大小: {}", DELAY_QUEUE.size());
+            return false;
+        }
         return DELAY_QUEUE.add(delayedItem);
     }
 
     @Override
     public boolean addToDelayQueue(DelayActivity activity) {
+        if (DELAY_QUEUE.size() >= MAX_QUEUE_SIZE) {
+            log.error("活动延迟队列已满,拒绝添加任务,当前大小: {}", DELAY_QUEUE.size());
+            return false;
+        }
         DelayedItem<DelayActivity> activityDelayed = new DelayedItem<>(activity, activity.getExecuteTime());
         return DELAY_QUEUE.add(activityDelayed);
     }
@@ -173,4 +198,25 @@ public class ActivityDelayJob implements DelayService<DelayActivity> {
         }
         return DELAY_QUEUE.removeIf(queue -> queue.data.getId().equals(activityId));
     }
+
+    /**
+     * 应用关闭时释放资源
+     */
+    @jakarta.annotation.PreDestroy
+    public void destroy() {
+        log.info("正在关闭活动延迟任务线程池...");
+        running = false;
+        executor.shutdown();
+        try {
+            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
+                executor.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            executor.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+        // 清理队列
+        DELAY_QUEUE.clear();
+        log.info("活动延迟任务线程池已关闭");
+    }
 }

+ 36 - 17
admin/src/main/java/com/kym/admin/jobs/InvoiceStatusJob.java

@@ -10,6 +10,9 @@ import com.kym.service.miniapp.ChargeOrderService;
 import com.kym.service.miniapp.InvoiceService;
 import com.kym.service.wechat.WxPayService;
 import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -43,24 +46,40 @@ public class InvoiceStatusJob {
     @Scheduled(cron = "0 0 18 * * ?")
     public void execute() {
         log.info("执行发票状态处理定时任务...开始");
-        // 所有开票中状态的发票
-        DynamicDataSourceContextHolder.push("db-miniapp");
-        var invoiceList = invoiceService.lambdaQuery().eq(Invoice::getStatus, Invoice.STATUS_开票中).list();
-        DynamicDataSourceContextHolder.poll();
-        invoiceList.forEach(invoice -> {
-            var fapiaoApplications = wxPayService.queryFapiao(invoice.getApplyId());
-            // 更新发票详情
-            invoiceDetailService.updateInvoiceDetail(invoice.getApplyId(), fapiaoApplications);
-            // 更新订单开票状态
-            if (InvoiceNotification.FapiaoStatus.ISSUED.name().equals(fapiaoApplications.getFapiao_information().get(0).getStatus())) {
-                var startChargeSeqs = invoice.getOrderDetails().stream().map(InvoiceOrderDetail::getStartChargeSeq).toList();
-                DynamicDataSourceContextHolder.push("db-miniapp");
-                chargeOrderService.lambdaUpdate().in(ChargeOrder::getStartChargeSeq, startChargeSeqs).set(ChargeOrder::getInvoiceStatus, ChargeOrder.INVOICE_STATUS_已开票).update();
-                // 修改发票状态
-                invoiceService.lambdaUpdate().eq(Invoice::getApplyId, invoice.getApplyId()).set(Invoice::getStatus, Invoice.STATUS_已开票).update();
-                DynamicDataSourceContextHolder.poll();
+            
+        // 查询所有开票中状态的发票
+        List<Invoice> invoiceList;
+        try {
+            DynamicDataSourceContextHolder.push("db-miniapp");
+            invoiceList = invoiceService.lambdaQuery().eq(Invoice::getStatus, Invoice.STATUS_开票中).list();
+        } finally {
+            DynamicDataSourceContextHolder.poll();
+        }
+            
+        // 处理每个发票
+        for (Invoice invoice : invoiceList) {
+            try {
+                var fapiaoApplications = wxPayService.queryFapiao(invoice.getApplyId());
+                // 更新发票详情
+                invoiceDetailService.updateInvoiceDetail(invoice.getApplyId(), fapiaoApplications);
+                // 更新订单开票状态
+                if (fapiaoApplications.getFapiao_information() != null 
+                        && !fapiaoApplications.getFapiao_information().isEmpty()
+                        && InvoiceNotification.FapiaoStatus.ISSUED.name().equals(fapiaoApplications.getFapiao_information().get(0).getStatus())) {
+                    var startChargeSeqs = invoice.getOrderDetails().stream().map(InvoiceOrderDetail::getStartChargeSeq).toList();
+                    try {
+                        DynamicDataSourceContextHolder.push("db-miniapp");
+                        chargeOrderService.lambdaUpdate().in(ChargeOrder::getStartChargeSeq, startChargeSeqs).set(ChargeOrder::getInvoiceStatus, ChargeOrder.INVOICE_STATUS_已开票).update();
+                        // 修改发票状态
+                        invoiceService.lambdaUpdate().eq(Invoice::getApplyId, invoice.getApplyId()).set(Invoice::getStatus, Invoice.STATUS_已开票).update();
+                    } finally {
+                        DynamicDataSourceContextHolder.poll();
+                    }
+                }
+            } catch (Exception e) {
+                log.error("处理发票{}失败", invoice.getApplyId(), e);
             }
-        });
+        }
         log.info("执行发票状态处理定时任务...结束");
     }
 

+ 31 - 17
admin/src/main/resources/application-dev.yml

@@ -47,36 +47,46 @@ wechat:
 spring:
   datasource:
     druid: #以下是全局默认值,可以全局更改
-      #监控统计拦截的filters
-      filters: stat,slf4j
+      #监控统计拦截的 filters
+      filters: stat,wall,slf4j
       #配置初始化大小/最小/最大
-      initial-size: 2
-      min-idle: 2
-      max-active: 20
-      #获取连接等待超时时间
-      max-wait: 60000
+      initial-size: 10
+      min-idle: 10
+      max-active: 50
+      #获取连接等待超时时间(毫秒)
+      max-wait: 5000
       #间隔多久进行一次检测,检测需要关闭的空闲连接
-      time-between-eviction-runs-millis: 60000
+      time-between-eviction-runs-millis: 30000
       #一个连接在池中最小生存的时间
       min-evictable-idle-time-millis: 300000
-      validation-query: SELECT 'x'
+      max-evictable-idle-time-millis: 900000
+      validation-query: SELECT 1
       test-while-idle: true
       test-on-borrow: false
       test-on-return: false
-      #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
-      pool-prepared-statements: false
-      max-pool-prepared-statement-per-connection-size: 20
+      #打开 PSCache,并指定每个连接上 PSCache 的大小。oracle 设为 true,mysql 设为 false。分库分表较多推荐设置为 false
+      pool-prepared-statements: true
+      max-pool-prepared-statement-per-connection-size: 50
     dynamic:
       primary: db-admin
-      strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
+      strict: false #严格匹配数据源,默认 false. true 未匹配到指定数据源时抛异常,false 使用默认数据源
+      druid:
+        # db-admin 数据源独立配置
+        db-admin:
+          max-active: 30
+          max-wait: 3000
+        # db-miniapp 数据源独立配置
+        db-miniapp:
+          max-active: 40
+          max-wait: 3000
       datasource:
         db-admin:
-          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_admin?serverTimezone=Asia/Shanghai
+          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_admin?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
           username: root
           password: KuaiyuMan/*-
           driver-class-name: com.mysql.cj.jdbc.Driver
         db-miniapp:
-          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_app?serverTimezone=Asia/Shanghai
+          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_app?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
           username: root
           password: KuaiyuMan/*-
           driver-class-name: com.mysql.cj.jdbc.Driver
@@ -86,13 +96,17 @@ spring:
       host: server.kuaiyuman.cn
       password: KtXA^Zx!TZmLEy(@JjB@2(TVG0kdy5)&
       database: 10
+      timeout: 3000ms
       lettuce:
         pool:
-          min-idle: 1
+          max-active: 50
+          max-idle: 20
+          min-idle: 5
+          max-wait: 2000ms
         cluster:
           refresh:
             adaptive: true
-            period: 20
+            period: 30
   cache:
     type: redis
     redis:

+ 16 - 7
admin/src/main/resources/application-prod.yml

@@ -50,11 +50,11 @@ spring:
       #监控统计拦截的filters
       filters: stat,slf4j
       #配置初始化大小/最小/最大
-      initial-size: 2
-      min-idle: 2
-      max-active: 20
-      #获取连接等待超时时间
-      max-wait: 60000
+      initial-size: 5
+      min-idle: 5
+      max-active: 50
+      #获取连接等待超时时间(从60秒优化到10秒)
+      max-wait: 10000
       #间隔多久进行一次检测,检测需要关闭的空闲连接
       time-between-eviction-runs-millis: 60000
       #一个连接在池中最小生存的时间
@@ -66,17 +66,26 @@ spring:
       #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
       pool-prepared-statements: false
       max-pool-prepared-statement-per-connection-size: 20
+      #连接泄漏检测配置
+      remove-abandoned: true
+      remove-abandoned-timeout: 300
+      log-abandoned: true
+      #连接超时配置
+      connect-timeout: 5000
+      socket-timeout: 60000
+      #慢SQL监控
+      connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=3000
     dynamic:
       primary: db-admin
       strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
       datasource:
         db-admin:
-          url: jdbc:mysql://127.0.0.1:3306/charge_admin?serverTimezone=Asia/Shanghai
+          url: jdbc:mysql://121.40.98.15:3306/charge_admin?serverTimezone=Asia/Shanghai
           username: kym
           password: qYhQLZLP6e7paVmQN5foEwRYJ1yFNpwM
           driver-class-name: com.mysql.cj.jdbc.Driver
         db-miniapp:
-          url: jdbc:mysql://127.0.0.1:3306/charge_app?serverTimezone=Asia/Shanghai
+          url: jdbc:mysql://121.40.98.15:3306/charge_app?serverTimezone=Asia/Shanghai
           username: kym
           password: qYhQLZLP6e7paVmQN5foEwRYJ1yFNpwM
           driver-class-name: com.mysql.cj.jdbc.Driver

+ 20 - 0
admin/src/main/resources/static/index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <meta name="keywords" content="vue-next-admin"/>
+    <meta name="description" content="快与慢运营管理平台"/>
+    <link rel="icon" href="./assets/logo.46850193.png"/>
+    <title>快与慢运营管理平台</title>
+
+  <script type="module" crossorigin src="./assets/index.569521c2.js"></script>
+  <link rel="modulepreload" crossorigin href="./assets/vue.6ae8aee9.js">
+  <link rel="stylesheet" href="./assets/index.63d29cea.css">
+</head>
+<body>
+<div id="app"></div>
+
+</body>
+</html>

BIN
admin/src/main/resources/static/logo.png


+ 2 - 2
common/pom.xml

@@ -97,11 +97,11 @@
             <artifactId>commons-pool2</artifactId>
         </dependency>
 
-        <!--oss文件服务器-->
+        <!--oss文件服务器: 3.16.1 -> 3.17.4 (功能增强和bug修复)-->
         <dependency>
             <groupId>com.aliyun.oss</groupId>
             <artifactId>aliyun-sdk-oss</artifactId>
-            <version>3.16.1</version>
+            <version>3.17.4</version>
         </dependency>
 
         <!-- STS SDK 用于临时授权访问OSS -->

+ 33 - 0
common/src/main/java/com/kym/common/config/JacksonConfig.java

@@ -10,6 +10,8 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
 
 import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.Set;
 
@@ -85,6 +87,19 @@ public class JacksonConfig {
         }
     }
 
+    public class JsonLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
+        private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        
+        @Override
+        public void serialize(LocalDateTime value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
+            if (value != null) {
+                jsonGenerator.writeString(value.format(formatter));
+            } else {
+                jsonGenerator.writeNull();
+            }
+        }
+    }
+
 
     public void serialize(Long aLong, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
         jsonGenerator.writeString(Long.toString(aLong));
@@ -106,6 +121,11 @@ public class JacksonConfig {
         private JsonSerializer nullNumberJsonSerializer = new MyNullNumberJsonSerializer();
 
         private JsonSerializer longSerializer = new JsonLongSerializer();
+        
+        /**
+         * LocalDateTime类型
+         */
+        private JsonSerializer localDateTimeSerializer = new JsonLocalDateTimeSerializer();
 
 
         /**
@@ -127,6 +147,8 @@ public class JacksonConfig {
                     if(isLongType(writer)){
                         writer.assignSerializer(this.longSerializer);
                     }
+                } else if (isLocalDateTimeType(writer)) {
+                    writer.assignSerializer(this.localDateTimeSerializer);
                 } else {
                     writer.assignNullSerializer(this.nullJsonSerializer);
                 }
@@ -167,6 +189,17 @@ public class JacksonConfig {
             return clazz.equals(Long.class);
         }
 
+        /**
+         * 判断是LocalDateTime类型
+         *
+         * @param writer
+         * @return
+         */
+        protected boolean isLocalDateTimeType(BeanPropertyWriter writer) {
+            Class clazz = writer.getPropertyType();
+            return clazz.equals(LocalDateTime.class);
+        }
+
 
     }
 

+ 4 - 0
common/src/main/java/com/kym/common/constant/ResponseEnum.java

@@ -48,6 +48,10 @@ public enum ResponseEnum implements BusinessExceptionAssert {
     PLATFORM_EQUIP_STOP_FAIL(20012, "设备停止充电失败"),
     PLATFORM_EQUIP_EXIST_ORDER_UNFINISHED(20013, "设备存在未完成的订单"),
     ORDER_IN_BOOKING(20014, "用户有预约中的订单"),
+    STATION_WHITELIST_NOT_ENABLED(20015, "站点未启用白名单功能"),
+    USER_NOT_IN_WHITELIST(20016, "非站点的白名单用户,无法启动充电"),
+    STATION_WHITELIST_ALREADY_EXISTS(20017, "用户已在该站点白名单中"),
+    STATION_WHITELIST_NOT_EXISTS(20018, "白名单记录不存在"),
 
 
     // 互联互通平台

+ 74 - 0
common/src/main/java/com/kym/common/exception/ChargeException.java

@@ -0,0 +1,74 @@
+package com.kym.common.exception;
+
+import com.kym.common.constant.ResponseEnum;
+
+/**
+ * 充电业务异常
+ * 用于处理充电过程中的各类业务异常
+ *
+ * @author skyline
+ */
+public class ChargeException extends BusinessException {
+
+    private static final long serialVersionUID = 1L;
+
+    public ChargeException(String message) {
+        super(ResponseEnum.HTTP_STATUS_500.getCode(), message);
+    }
+
+    public ChargeException(Integer code, String message) {
+        super(code, message);
+    }
+
+    public ChargeException(ResponseEnum responseEnum) {
+        super(responseEnum);
+    }
+
+    public ChargeException(ResponseEnum responseEnum, Throwable cause) {
+        super(responseEnum.getCode(), responseEnum.getMessage());
+        this.initCause(cause);
+    }
+
+    /**
+     * 充电枪离线异常
+     */
+    public static class ConnectorOfflineException extends ChargeException {
+        public ConnectorOfflineException(String connectorId) {
+            super("充电枪" + connectorId + "离线,请稍后重试");
+        }
+    }
+
+    /**
+     * 余额不足异常
+     */
+    public static class InsufficientBalanceException extends ChargeException {
+        private final Double balance;
+
+        public InsufficientBalanceException(Double balance) {
+            super("用户余额不足,当前余额:" + balance);
+            this.balance = balance;
+        }
+
+        public Double getBalance() {
+            return balance;
+        }
+    }
+
+    /**
+     * 预约冲突异常
+     */
+    public static class BookingConflictException extends ChargeException {
+        public BookingConflictException(String message) {
+            super(message);
+        }
+    }
+
+    /**
+     * 设备状态异常
+     */
+    public static class EquipmentStatusException extends ChargeException {
+        public EquipmentStatusException(String equipmentId, String status) {
+            super("设备" + equipmentId + "状态异常:" + status);
+        }
+    }
+}

+ 69 - 0
common/src/main/java/com/kym/common/exception/PlatformCommunicationException.java

@@ -0,0 +1,69 @@
+package com.kym.common.exception;
+
+/**
+ * 平台通信异常
+ * 用于处理与第三方平台(如 EN+)通信时的异常
+ *
+ * @author skyline
+ */
+public class PlatformCommunicationException extends BusinessException {
+
+    private static final long serialVersionUID = 1L;
+
+    private final String platformName;
+    private final String apiName;
+
+    public PlatformCommunicationException(String platformName, String message) {
+        super("连接" + platformName + "平台失败:" + message);
+        this.platformName = platformName;
+        this.apiName = null;
+    }
+
+    public PlatformCommunicationException(String platformName, String apiName, String message) {
+        super("调用" + platformName + "平台接口" + apiName + "失败:" + message);
+        this.platformName = platformName;
+        this.apiName = apiName;
+    }
+
+    public PlatformCommunicationException(String platformName, Throwable cause) {
+        super("连接" + platformName + "平台异常:" + cause.getMessage());
+        this.platformName = platformName;
+        this.apiName = null;
+        this.initCause(cause);
+    }
+
+    public String getPlatformName() {
+        return platformName;
+    }
+
+    public String getApiName() {
+        return apiName;
+    }
+
+    /**
+     * 平台认证失败异常
+     */
+    public static class PlatformAuthException extends PlatformCommunicationException {
+        public PlatformAuthException(String platformName) {
+            super(platformName, "认证失败,请检查密钥配置");
+        }
+    }
+
+    /**
+     * 平台超时异常
+     */
+    public static class PlatformTimeoutException extends PlatformCommunicationException {
+        public PlatformTimeoutException(String platformName, String apiName) {
+            super(platformName, apiName, "请求超时,请稍后重试");
+        }
+    }
+
+    /**
+     * 平台数据格式异常
+     */
+    public static class PlatformDataFormatException extends PlatformCommunicationException {
+        public PlatformDataFormatException(String platformName, String message) {
+            super(platformName, "数据格式错误:" + message);
+        }
+    }
+}

+ 65 - 4
common/src/main/java/com/kym/common/handler/GlobalExceptionHandler.java

@@ -6,6 +6,8 @@ import cn.dev33.satoken.exception.NotRoleException;
 import com.kym.common.R;
 import com.kym.common.exception.BaseException;
 import com.kym.common.exception.BusinessException;
+import com.kym.common.exception.ChargeException;
+import com.kym.common.exception.PlatformCommunicationException;
 import com.kym.common.exception.PlatformPushException;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.ConstraintViolationException;
@@ -142,10 +144,69 @@ public class GlobalExceptionHandler {
     }
 
 
-    @ExceptionHandler(value = {Exception.class})
-    public R handlerRestException(Exception e) {
-        LOGGER.info(e.getMessage(), e);
-        return R.failed();
+    /**
+     * 充电业务异常处理
+     *
+     * @param e 异常
+     * @return 异常结果
+     */
+    @ExceptionHandler(value = ChargeException.class)
+    @ResponseBody
+    public R<?> handleChargeException(ChargeException e) {
+        LOGGER.error("充电业务异常:{}", e.getMessage(), e);
+        return R.failed(e.getCode(), e.getMessage());
+    }
+
+    /**
+     * 平台通信异常处理
+     *
+     * @param e 异常
+     * @return 异常结果
+     */
+    @ExceptionHandler(value = PlatformCommunicationException.class)
+    @ResponseBody
+    public R<?> handlePlatformCommunicationException(PlatformCommunicationException e) {
+        LOGGER.error("平台通信异常:platform={}, api={}", e.getPlatformName(), e.getApiName(), e);
+        // 根据异常类型返回不同的错误信息
+        if (e instanceof PlatformCommunicationException.PlatformAuthException) {
+            return R.failed(503, "平台认证失败,请联系管理员");
+        } else if (e instanceof PlatformCommunicationException.PlatformTimeoutException) {
+            return R.failed(504, "平台响应超时,请稍后重试");
+        } else if (e instanceof PlatformCommunicationException.PlatformDataFormatException) {
+            return R.failed(500, "数据格式错误,请检查配置");
+        }
+        return R.failed(503, "外部服务不可用,请稍后重试");
+    }
+
+    /**
+     * 未知异常处理(兜底)
+     *
+     * @param e 异常
+     * @return 异常结果
+     */
+    @ExceptionHandler(value = Exception.class)
+    @ResponseBody
+    public R<?> handlerRestException(Exception e) {
+        // 记录完整的堆栈信息用于排查问题
+        LOGGER.error("系统未知异常:URI={}, Message={}", 
+            getCurrentRequestURI(), e.getMessage(), e);
+        return R.failed(HttpStatus.INTERNAL_SERVER_ERROR.value(), "系统繁忙,请稍后重试");
+    }
+
+    /**
+     * 获取当前请求 URI(用于日志记录)
+     */
+    private String getCurrentRequestURI() {
+        try {
+            var requestAttributes = org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes();
+            if (requestAttributes instanceof org.springframework.web.context.request.ServletRequestAttributes) {
+                var request = ((org.springframework.web.context.request.ServletRequestAttributes) requestAttributes).getRequest();
+                return request.getRequestURI();
+            }
+        } catch (Exception e) {
+            // 忽略异常,返回 unknown
+        }
+        return "unknown";
     }
 
 

+ 4 - 20
common/src/main/java/com/kym/common/utils/CommUtil.java

@@ -75,29 +75,13 @@ public class CommUtil {
     }
 
     /**
-     * privatmitive
      * 判断一个对象是否是基础数据类型或String
+     * 使用Java 21 instanceof pattern matching优化
      */
     public static boolean isBasicType(Object obj) {
-        if (obj instanceof String) {
-            return true;
-        } else if (obj instanceof Integer) {
-            return true;
-        } else if (obj instanceof Short) {
-            return true;
-        } else if (obj instanceof Float) {
-            return true;
-        } else if (obj instanceof Double) {
-            return true;
-        } else if (obj instanceof Byte) {
-            return true;
-        } else if (obj instanceof Boolean) {
-            return true;
-        } else if (obj instanceof Long) {
-            return true;
-        } else {
-            return obj instanceof Character;
-        }
+        return obj instanceof String || obj instanceof Integer || obj instanceof Short ||
+               obj instanceof Float || obj instanceof Double || obj instanceof Byte ||
+               obj instanceof Boolean || obj instanceof Long || obj instanceof Character;
     }
 
     public static boolean isNotEmptyAndNull(Object value) {

+ 1 - 1
entity/pom.xml

@@ -58,10 +58,10 @@
             <artifactId>gson</artifactId>
         </dependency>
 
+        <!-- Jackson JSR310: 由Spring Boot统一管理版本 -->
         <dependency>
             <groupId>com.fasterxml.jackson.datatype</groupId>
             <artifactId>jackson-datatype-jsr310</artifactId>
-            <version>${jackson-datatype-jsr310.version}</version>
         </dependency>
     </dependencies>
 

+ 39 - 0
entity/src/main/java/com/kym/entity/admin/StationWhitelist.java

@@ -0,0 +1,39 @@
+package com.kym.entity.admin;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.kym.entity.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+@Getter
+@Setter
+@TableName("t_station_whitelist")
+@Accessors(chain = true)
+public class StationWhitelist extends BaseEntity implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final int STATUS_禁用 = 0;
+    public static final int STATUS_启用 = 1;
+
+    private Long companyId;
+
+    private String stationId;
+
+    private String stationName;
+
+    private Long userId;
+
+    private String userPhone;
+
+    private String userNickname;
+
+    private Integer status;
+
+    private String remark;
+
+    private Long createBy;
+}

+ 31 - 0
entity/src/main/java/com/kym/entity/admin/StationWhitelistConfig.java

@@ -0,0 +1,31 @@
+package com.kym.entity.admin;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.kym.entity.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+@Getter
+@Setter
+@TableName("t_station_whitelist_config")
+@Accessors(chain = true)
+public class StationWhitelistConfig extends BaseEntity implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final int ENABLED_否 = 0;
+    public static final int ENABLED_是 = 1;
+
+    private Long companyId;
+
+    private String stationId;
+
+    private String stationName;
+
+    private Integer enabled;
+
+    private Long createBy;
+}

+ 44 - 0
entity/src/main/java/com/kym/entity/admin/StationWhitelistLog.java

@@ -0,0 +1,44 @@
+package com.kym.entity.admin;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.kym.entity.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+@Getter
+@Setter
+@TableName("t_station_whitelist_log")
+@Accessors(chain = true)
+public class StationWhitelistLog extends BaseEntity implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final int OPERATION_TYPE_添加 = 1;
+    public static final int OPERATION_TYPE_删除 = 2;
+    public static final int OPERATION_TYPE_启用 = 3;
+    public static final int OPERATION_TYPE_禁用 = 4;
+    public static final int OPERATION_TYPE_批量添加 = 5;
+    public static final int OPERATION_TYPE_批量删除 = 6;
+    public static final int OPERATION_TYPE_配置变更 = 7;
+
+    private Long companyId;
+
+    private String stationId;
+
+    private String stationName;
+
+    private Long userId;
+
+    private String userPhone;
+
+    private Integer operationType;
+
+    private String operationDesc;
+
+    private Long operatorId;
+
+    private String operatorName;
+}

+ 18 - 0
entity/src/main/java/com/kym/entity/admin/dto/BatchAddWhitelistDto.java

@@ -0,0 +1,18 @@
+package com.kym.entity.admin.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class BatchAddWhitelistDto implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String stationId;
+
+    private List<Long> userIds;
+
+    private String remark;
+}

+ 17 - 0
entity/src/main/java/com/kym/entity/admin/queryParams/StationWhitelistQueryParam.java

@@ -0,0 +1,17 @@
+package com.kym.entity.admin.queryParams;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class StationWhitelistQueryParam extends CommonQueryParam {
+
+    private String stationId;
+
+    private String userPhone;
+
+    private String userNickname;
+
+    private Integer status;
+}

+ 24 - 0
entity/src/main/java/com/kym/entity/admin/vo/StationWhitelistConfigVo.java

@@ -0,0 +1,24 @@
+package com.kym.entity.admin.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+public class StationWhitelistConfigVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private String stationId;
+
+    private String stationName;
+
+    private Integer enabled;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 32 - 0
entity/src/main/java/com/kym/entity/admin/vo/StationWhitelistVo.java

@@ -0,0 +1,32 @@
+package com.kym.entity.admin.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+public class StationWhitelistVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private String stationId;
+
+    private String stationName;
+
+    private Long userId;
+
+    private String userPhone;
+
+    private String userNickname;
+
+    private Integer status;
+
+    private String remark;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

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

@@ -16,6 +16,10 @@ public interface RedisKeys {
     String OFFLINE = "OFFLINE:";
     String OFFLINE_EXPIRED = "OFFLINE_EXPIRED:";
     String CONNECTOR_ID_TO_SHORT_ID = "CONNECTOR_ID_TO_SHORT_ID:";
+    /**
+     * 短编号到connectorId的反向索引
+     */
+    String SHORT_ID_TO_CONNECTOR_ID = "SHORT_ID_TO_CONNECTOR_ID:";
     String CONNECTOR_ID_TO_STATUS = "CONNECTOR_ID_TO_STATUS:";
     String CONNECTOR_ID_TO_STATION_ID = "CONNECTOR_ID_TO_STATION_ID:";
     String STATION_ID_TO_NAME = "STATION_ID_TO_NAME:";
@@ -31,4 +35,14 @@ public interface RedisKeys {
      * 站点费率
      */
     String STATION_ID_POLICY_INFO = "STATION_ID_POLICY_INFO:";
+
+    /**
+     * 站点白名单配置(站点ID -> 是否启用)
+     */
+    String STATION_WHITELIST_CONFIG = "STATION_WHITELIST_CONFIG:";
+
+    /**
+     * 站点白名单用户列表(站点ID -> 用户ID集合)
+     */
+    String STATION_WHITELIST_USERS = "STATION_WHITELIST_USERS:";
 }

+ 31 - 0
entity/src/main/java/com/kym/entity/miniapp/vo/RefundVo.java

@@ -107,4 +107,35 @@ public class RefundVo {
      */
     private String adminUsername;
 
+    /**
+     * 支付成功时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime paySuccessTime;
+
+    /**
+     * 是否超过退款时限(365天)
+     */
+    private Boolean isOverdue;
+
+    /**
+     * 充值时间(来自t_pay_log的success_time)
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime rechargeTime;
+
+    // 添加 getter/setter 方法以确保正确映射
+    public Boolean getIsOverdue() {
+        return isOverdue;
+    }
+
+    public void setIsOverdue(Boolean isOverdue) {
+        this.isOverdue = isOverdue;
+    }
+
+    // 为了处理 MySQL 返回的整数类型,添加一个辅助方法
+    public void setIsOverdueFromInt(Integer value) {
+        this.isOverdue = (value != null && value == 1);
+    }
+
 }

+ 8 - 0
mapper/src/main/java/com/kym/mapper/admin/StationWhitelistConfigMapper.java

@@ -0,0 +1,8 @@
+package com.kym.mapper.admin;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.admin.StationWhitelistConfig;
+
+public interface StationWhitelistConfigMapper extends MPJBaseMapper<StationWhitelistConfig> {
+
+}

+ 8 - 0
mapper/src/main/java/com/kym/mapper/admin/StationWhitelistLogMapper.java

@@ -0,0 +1,8 @@
+package com.kym.mapper.admin;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.admin.StationWhitelistLog;
+
+public interface StationWhitelistLogMapper extends MPJBaseMapper<StationWhitelistLog> {
+
+}

+ 8 - 0
mapper/src/main/java/com/kym/mapper/admin/StationWhitelistMapper.java

@@ -0,0 +1,8 @@
+package com.kym.mapper.admin;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.admin.StationWhitelist;
+
+public interface StationWhitelistMapper extends MPJBaseMapper<StationWhitelist> {
+
+}

+ 12 - 2
mapper/src/main/resources/mappers/miniapp/RefundLogMapper.xml

@@ -43,6 +43,9 @@
         <result column="reason" property="reason" />
         <result column="adminUserId" property="admin_user_id" />
         <result column="adminUsername" property="admin_username" />
+        <result column="pay_success_time" property="paySuccessTime" />
+        <result column="is_overdue" property="isOverdue" typeHandler="org.apache.ibatis.type.BooleanTypeHandler" />
+        <result column="recharge_time" property="rechargeTime" />
     </resultMap>
 
     <!-- 通用查询结果列 -->
@@ -68,11 +71,16 @@
             t1.`total`,
             t1.`refund`,
             t1.`discount_amount`,
-            t1.`channel`,
             t1.`currency`,
             t1.`reason`,
             t1.`admin_user_id`,
-            t1.`admin_username`
+            t1.`admin_username`,
+            t5.success_time as pay_success_time,
+            t5.success_time as recharge_time,
+            CASE 
+                WHEN t5.success_time IS NOT NULL AND DATE_ADD(t5.success_time, INTERVAL 365 DAY) &lt; NOW() THEN 1 
+                ELSE 0 
+            END as is_overdue
         FROM
             t_refund_log t1
                 LEFT JOIN
@@ -86,6 +94,8 @@
                      LEFT JOIN t_account t3
                                ON t2.id = t3.user_id) t4
             ON t1.user_id = t4.user_id
+                LEFT JOIN t_pay_log t5
+                    ON CONVERT(t1.out_trade_no USING utf8mb4) = CONVERT(t5.out_trade_no USING utf8mb4)
         <where>
             <if test="params.mobilePhone != null and params.mobilePhone != ''  ">
                 and t4.mobile_phone = #{params.mobilePhone}

+ 26 - 3
miniapp/src/main/java/com/kym/miniapp/jobs/StartChargeDelayJob.java

@@ -10,6 +10,7 @@ import com.kym.service.jobs.DelayService;
 import com.kym.service.jobs.DelayedItem;
 import com.kym.service.miniapp.ChargeOrderService;
 import com.kym.service.miniapp.ChargeService;
+import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.config.ConfigurableBeanFactory;
 import org.springframework.context.annotation.Scope;
@@ -23,6 +24,9 @@ import java.util.ArrayList;
 import java.util.concurrent.DelayQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 
 /**
  * @author skyline
@@ -44,9 +48,10 @@ public class StartChargeDelayJob implements DelayService<DelayChargeOrder> {
 
     private final static int MAX_THREAD_COUNT = 5;
     /**
-     * 线程池
+     * Java 21 虚拟线程池
+     * 替代传统的ThreadPoolExecutor,提供更好的I/O密集型任务性能
      */
-    private final ExecutorService executor = Executors.newFixedThreadPool(MAX_THREAD_COUNT);
+    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
 
     /**
      * 重试列表
@@ -165,4 +170,22 @@ public class StartChargeDelayJob implements DelayService<DelayChargeOrder> {
         }
         return START_DELAY_QUEUE.removeIf(queue -> queue.data.getStartChargeSeq().equals(startChargeSeq));
     }
-}
+
+    @PreDestroy
+    public void destroy() {
+        log.info("正在关闭启动充电延迟任务线程池...");
+        executor.shutdown();
+        try {
+            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
+                executor.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            executor.shutdownNow();
+        }
+        log.info("启动充电延迟任务线程池已关闭");
+        // 清理队列
+        START_DELAY_QUEUE.clear();
+        // 清理重试列表
+        retryList.clear();
+    }
+}

+ 60 - 37
miniapp/src/main/java/com/kym/miniapp/jobs/StopChargeDelayJob.java

@@ -9,6 +9,7 @@ import com.kym.service.jobs.DelayService;
 import com.kym.service.jobs.DelayedItem;
 import com.kym.service.miniapp.ChargeOrderService;
 import com.kym.service.miniapp.ChargeService;
+import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.config.ConfigurableBeanFactory;
 import org.springframework.context.annotation.Scope;
@@ -20,6 +21,7 @@ import org.springframework.stereotype.Component;
 import java.util.concurrent.DelayQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 
 /**
  * @author skyline
@@ -42,7 +44,12 @@ public class StopChargeDelayJob implements DelayService<DelayChargeOrder> {
     /**
      * 线程池
      */
-    private final ExecutorService executor = Executors.newFixedThreadPool(1);
+    private final static int MAX_THREAD_COUNT = 3;
+    /**
+     * Java 21 虚拟线程池
+     * 替代传统的ThreadPoolExecutor,提供更好的I/O密集型任务性能
+     */
+    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
 
     public StopChargeDelayJob(ChargeOrderService chargeOrderService, ChargeService chargeService) {
         this.chargeOrderService = chargeOrderService;
@@ -78,48 +85,48 @@ public class StopChargeDelayJob implements DelayService<DelayChargeOrder> {
     }
 
     private void processDelayedOrders() {
-        while (true) {
+        // 启动线程池中的所有线程处理延迟队列
+        for (int i = 0; i < MAX_THREAD_COUNT; i++) {
             executor.execute(() -> {
                 ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> null); // 初始化为空值,避免使用new ThreadLocal()
                 log.info("预约停止充电处理线程:{}", Thread.currentThread().getName());
-                DelayedItem<DelayChargeOrder> delayedItem;
-
-                try {
-                    // 线程休眠300ms
-                    Thread.sleep(300);
-                    delayedItem = STOP_DELAY_QUEUE.take();
-                    // 停止充电
-                    var order = delayedItem.data;
-                    threadLocal.set(order.getStartChargeSeq());
-                    // 查询该设备最新订单是否是当前预约订单,是则按计划停止
-                    var currentChargeOrder = chargeOrderService.lambdaQuery().eq(ChargeOrder::getConnectorId, order.getConnectorId()).orderByDesc(ChargeOrder::getCreateTime).list().get(0);
-                    if (currentChargeOrder.getStartChargeSeq().equals(order.getStartChargeSeq())) {
-                        chargeService.queryStopCharge(order.getUserId(), order.getConnectorId());
-                        log.info("预约充电停止成功:用户:{},订单号:{},预约停止时间:{}", order.getUserId(), order.getStartChargeSeq(), order.getEndTime());
-
-                    } else {
-                        log.error("预约充电停止异常:订单不匹配:原订单:{},当前设备最新订单:{}", order.getStartChargeSeq(), currentChargeOrder.getStartChargeSeq());
-                        throw new BusinessException("预约充电停止异常");
+                while (true) {
+                    DelayedItem<DelayChargeOrder> delayedItem = null;
+                    try {
+                        // 阻塞等待延迟队列中的任务
+                        delayedItem = STOP_DELAY_QUEUE.take();
+                        log.info("出队预约停止充电订单:{}, 队列剩余:{}", delayedItem.data.getStartChargeSeq(), STOP_DELAY_QUEUE.size());
+                        // 停止充电
+                        var order = delayedItem.data;
+                        threadLocal.set(order.getStartChargeSeq());
+                        // 查询该设备最新订单是否是当前预约订单,是则按计划停止(优化:使用LIMIT 1代替查询所有)
+                        var currentChargeOrder = chargeOrderService.lambdaQuery()
+                                .eq(ChargeOrder::getConnectorId, order.getConnectorId())
+                                .orderByDesc(ChargeOrder::getCreateTime)
+                                .last("LIMIT 1")
+                                .one();
+                        if (currentChargeOrder.getStartChargeSeq().equals(order.getStartChargeSeq())) {
+                            chargeService.queryStopCharge(order.getUserId(), order.getConnectorId());
+                            log.info("预约充电停止成功:用户:{}, 订单号:{}, 预约停止时间:{}", order.getUserId(), order.getStartChargeSeq(), order.getEndTime());
+
+                        } else {
+                            log.error("预约充电停止异常:订单不匹配:原订单:{}, 当前设备最新订单:{}", order.getStartChargeSeq(), currentChargeOrder.getStartChargeSeq());
+                            throw new BusinessException("预约充电停止异常");
+                        }
+
+                    } catch (Exception e) {
+                        if (e instanceof InterruptedException) {
+                            log.error("预约停止充电队列take异常", e);
+                            // 线程被中断,退出循环
+                            break;
+                        } else {
+                            log.info("预约停止充电失败,订单号:{}", threadLocal.get(), e);
+                        }
+                    } finally {
+                        threadLocal.remove();
                     }
-
-                } catch (Exception e) {
-                    if (e instanceof InterruptedException) {
-                        log.error("预约停止充电队列take异常", e);
-                    } else {
-                        log.info("预约停止充电失败,订单号:{}", threadLocal.get(), e);
-                    }
-                } finally {
-                    threadLocal.remove();
                 }
             });
-            if (!executor.isTerminated()) {
-                try {
-                    Thread.sleep(100);
-                } catch (InterruptedException e) {
-                    log.error("Delay queue processing interrupted.", e);
-                    return;
-                }
-            }
         }
     }
 
@@ -141,4 +148,20 @@ public class StopChargeDelayJob implements DelayService<DelayChargeOrder> {
         }
         return STOP_DELAY_QUEUE.removeIf(queue -> queue.data.getStartChargeSeq().equals(startChargeSeq));
     }
+
+    @PreDestroy
+    public void destroy() {
+        log.info("正在关闭停止充电延迟任务线程池...");
+        executor.shutdown();
+        try {
+            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
+                executor.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            executor.shutdownNow();
+        }
+        log.info("停止充电延迟任务线程池已关闭");
+        // 清理队列
+        STOP_DELAY_QUEUE.clear();
+    }
 }

+ 31 - 17
miniapp/src/main/resources/application-dev.yml

@@ -40,36 +40,46 @@ wechat:
 spring:
   datasource:
     druid: #以下是全局默认值,可以全局更改
-      #监控统计拦截的filters
-      filters: stat,slf4j
+      #监控统计拦截的 filters
+      filters: stat,wall,slf4j
       #配置初始化大小/最小/最大
-      initial-size: 2
-      min-idle: 2
-      max-active: 20
-      #获取连接等待超时时间
-      max-wait: 60000
+      initial-size: 10
+      min-idle: 10
+      max-active: 50
+      #获取连接等待超时时间(毫秒)
+      max-wait: 5000
       #间隔多久进行一次检测,检测需要关闭的空闲连接
-      time-between-eviction-runs-millis: 60000
+      time-between-eviction-runs-millis: 30000
       #一个连接在池中最小生存的时间
       min-evictable-idle-time-millis: 300000
-      validation-query: SELECT 'x'
+      max-evictable-idle-time-millis: 900000
+      validation-query: SELECT 1
       test-while-idle: true
       test-on-borrow: false
       test-on-return: false
-      #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
-      pool-prepared-statements: false
-      max-pool-prepared-statement-per-connection-size: 20
+      #打开 PSCache,并指定每个连接上 PSCache 的大小。oracle 设为 true,mysql 设为 false。分库分表较多推荐设置为 false
+      pool-prepared-statements: true
+      max-pool-prepared-statement-per-connection-size: 50
     dynamic:
       primary: db-miniapp
-      strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
+      strict: false #严格匹配数据源,默认 false. true 未匹配到指定数据源时抛异常,false 使用默认数据源
+      druid:
+        # db-admin 数据源独立配置
+        db-admin:
+          max-active: 30
+          max-wait: 3000
+        # db-miniapp 数据源独立配置
+        db-miniapp:
+          max-active: 40
+          max-wait: 3000
       datasource:
         db-admin:
-          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_admin?tinyInt1isBit=false&serverTimezone=Asia/Shanghai
+          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_admin?tinyInt1isBit=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
           username: root
           password: KuaiyuMan/*-
           driver-class-name: com.mysql.cj.jdbc.Driver
         db-miniapp:
-          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_app
+          url: jdbc:mysql://server.kuaiyuman.cn:3306/charge_app?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
           username: root
           password: KuaiyuMan/*-
           driver-class-name: com.mysql.cj.jdbc.Driver
@@ -79,13 +89,17 @@ spring:
       host: server.kuaiyuman.cn
       password: KtXA^Zx!TZmLEy(@JjB@2(TVG0kdy5)&
       database: 10
+      timeout: 3000ms
       lettuce:
         pool:
-          min-idle: 1
+          max-active: 50
+          max-idle: 20
+          min-idle: 5
+          max-wait: 2000ms
         cluster:
           refresh:
             adaptive: true
-            period: 20
+            period: 30
   cache:
     type: redis
     redis:

+ 14 - 5
miniapp/src/main/resources/application-prod.yml

@@ -43,11 +43,11 @@ spring:
       #监控统计拦截的filters
       filters: stat,slf4j
       #配置初始化大小/最小/最大
-      initial-size: 2
-      min-idle: 2
-      max-active: 20
-      #获取连接等待超时时间
-      max-wait: 60000
+      initial-size: 5
+      min-idle: 5
+      max-active: 50
+      #获取连接等待超时时间(从60秒优化到10秒)
+      max-wait: 10000
       #间隔多久进行一次检测,检测需要关闭的空闲连接
       time-between-eviction-runs-millis: 60000
       #一个连接在池中最小生存的时间
@@ -59,6 +59,15 @@ spring:
       #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
       pool-prepared-statements: false
       max-pool-prepared-statement-per-connection-size: 20
+      #连接泄漏检测配置
+      remove-abandoned: true
+      remove-abandoned-timeout: 300
+      log-abandoned: true
+      #连接超时配置
+      connect-timeout: 5000
+      socket-timeout: 60000
+      #慢SQL监控
+      connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=3000
     dynamic:
       primary: db-miniapp
       strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源

+ 23 - 13
pom.xml

@@ -27,24 +27,34 @@
 
         <spring-boot.version>${project.parent.version}</spring-boot.version>
         <project.version>0.0.1-SNAPSHOT</project.version>
-        <mybatis-plus-boot-starter.version>3.5.3.2</mybatis-plus-boot-starter.version>
-        <mybatis-plus-generator.version>3.5.3.2</mybatis-plus-generator.version>
+        <!-- MyBatis-Plus: 3.5.3.2 -> 3.5.5 (bug修复和性能优化) -->
+        <mybatis-plus-boot-starter.version>3.5.5</mybatis-plus-boot-starter.version>
+        <mybatis-plus-generator.version>3.5.5</mybatis-plus-generator.version>
         <mybatis-plus-join-boot-starter.version>1.5.2</mybatis-plus-join-boot-starter.version>
-        <wechatpay.version>0.2.11</wechatpay.version>
-        <jackson-datatype-jsr310.version>2.13.4</jackson-datatype-jsr310.version>
-        <fastjson2.version>2.0.35</fastjson2.version>
+        <!-- WeChat Pay: 0.2.11 -> 0.2.14 (bug修复) -->
+        <wechatpay.version>0.2.14</wechatpay.version>
+        <!-- FastJSON2: 2.0.35 -> 2.0.52 (安全补丁和性能优化) -->
+        <fastjson2.version>2.0.52</fastjson2.version>
         <pagehelper-spring-boot-starter.version>1.4.7</pagehelper-spring-boot-starter.version>
-        <freemarker.version>2.3.32</freemarker.version>
-        <dynamic-datasource-spring-boot3-starter.version>4.1.3</dynamic-datasource-spring-boot3-starter.version>
-        <druid-spring-boot-starter.version>1.2.20</druid-spring-boot-starter.version>
-        <poi-ooxml.version>5.2.3</poi-ooxml.version>
+        <!-- Freemarker: 2.3.32 -> 2.3.33 (bug修复) -->
+        <freemarker.version>2.3.33</freemarker.version>
+        <!-- Dynamic DataSource: 4.1.3 -> 4.3.1 (性能优化和bug修复) -->
+        <dynamic-datasource-spring-boot3-starter.version>4.3.1</dynamic-datasource-spring-boot3-starter.version>
+        <!-- Druid: 1.2.20 -> 1.2.23 (安全补丁) -->
+        <druid-spring-boot-starter.version>1.2.23</druid-spring-boot-starter.version>
+        <!-- POI: 5.2.3 -> 5.2.5 (bug修复) -->
+        <poi-ooxml.version>5.2.5</poi-ooxml.version>
         <javax.mail.version>1.6.2</javax.mail.version>
         <weixin-java.version>4.7.0</weixin-java.version>
-        <hutool.version>5.8.21</hutool.version>
-        <okhttp.version>4.11.0</okhttp.version>
-        <sa-token.version>1.38.0</sa-token.version>
+        <!-- Hutool: 5.8.21 -> 5.8.27 (bug修复) -->
+        <hutool.version>5.8.27</hutool.version>
+        <!-- OkHttp: 4.11.0 -> 4.12.0 (bug修复和性能优化) -->
+        <okhttp.version>4.12.0</okhttp.version>
+        <!-- Sa-Token: 1.38.0 -> 1.39.0 (新功能和bug修复) -->
+        <sa-token.version>1.39.0</sa-token.version>
         <jakarta.servlet-api.version>6.0.0</jakarta.servlet-api.version>
-        <gson.version>2.10.1</gson.version>
+        <!-- Gson: 2.10.1 -> 2.11.0 (性能优化) -->
+        <gson.version>2.11.0</gson.version>
         <jwt.version>4.4.0</jwt.version>
         <jjwt-api.version>0.11.5</jjwt-api.version>
         <geodesy.version.version>1.1.3</geodesy.version.version>

+ 22 - 0
service/src/main/java/com/kym/service/admin/StationWhitelistConfigService.java

@@ -0,0 +1,22 @@
+package com.kym.service.admin;
+
+import com.github.yulichang.base.MPJBaseService;
+import com.kym.entity.admin.StationWhitelistConfig;
+import com.kym.entity.admin.vo.StationWhitelistConfigVo;
+
+import java.util.List;
+
+public interface StationWhitelistConfigService extends MPJBaseService<StationWhitelistConfig> {
+
+    List<StationWhitelistConfigVo> listConfig();
+
+    StationWhitelistConfig getConfigByStationId(String stationId);
+
+    void updateConfig(String stationId, Integer enabled);
+
+    void deleteConfig(String stationId);
+
+    boolean isWhitelistEnabled(String stationId);
+
+    void refreshConfigCache();
+}

+ 19 - 0
service/src/main/java/com/kym/service/admin/StationWhitelistLogService.java

@@ -0,0 +1,19 @@
+package com.kym.service.admin;
+
+import com.github.yulichang.base.MPJBaseService;
+import com.kym.entity.admin.StationWhitelistLog;
+
+public interface StationWhitelistLogService extends MPJBaseService<StationWhitelistLog> {
+
+    void logAdd(String stationId, String stationName, Long userId, String userPhone, Long operatorId, String operatorName);
+
+    void logBatchAdd(String stationId, String stationName, int count, Long operatorId, String operatorName);
+
+    void logRemove(String stationId, String stationName, Long userId, String userPhone, Long operatorId, String operatorName);
+
+    void logBatchRemove(String stationId, String stationName, int count, Long operatorId, String operatorName);
+
+    void logStatusChange(String stationId, String stationName, Long userId, String userPhone, Integer status, Long operatorId, String operatorName);
+
+    void logConfigChange(String stationId, String stationName, Integer enabled, Long operatorId, String operatorName);
+}

+ 33 - 0
service/src/main/java/com/kym/service/admin/StationWhitelistService.java

@@ -0,0 +1,33 @@
+package com.kym.service.admin;
+
+import com.github.yulichang.base.MPJBaseService;
+import com.kym.entity.admin.StationWhitelist;
+import com.kym.entity.admin.dto.BatchAddWhitelistDto;
+import com.kym.entity.admin.queryParams.StationWhitelistQueryParam;
+import com.kym.entity.admin.vo.StationWhitelistVo;
+import com.kym.entity.common.PageBean;
+
+import java.util.List;
+
+public interface StationWhitelistService extends MPJBaseService<StationWhitelist> {
+
+    PageBean<StationWhitelistVo> listWhitelist(StationWhitelistQueryParam param);
+
+    void addWhitelist(StationWhitelist whitelist);
+
+    void batchAddWhitelist(BatchAddWhitelistDto dto);
+
+    void removeWhitelist(Long id);
+
+    void batchRemoveWhitelist(List<Long> ids);
+
+    void updateStatus(Long id, Integer status);
+
+    boolean isInWhitelist(String stationId, Long userId);
+
+    List<Long> getWhitelistUserIds(String stationId);
+
+    void refreshCache(String stationId);
+
+    void refreshAllCache();
+}

+ 3 - 1
service/src/main/java/com/kym/service/admin/impl/StationServiceImpl.java

@@ -249,7 +249,9 @@ public class StationServiceImpl extends MyBaseServiceImpl<StationMapper, Station
         // 查询正在进行中的活动和各站点正在进行中的活动 (限充值优惠和优惠券领取活动) todo 性能待优化
         var rechargeRightsActivityList = activityService.lambdaQuery().eq(Activity::getStatus, Activity.STATUS_进行中).eq(Activity::getDiscountType, Activity.DISCOUNT_TYPE_服务费折扣权益).list();
         var couponActivityList = activityService.getAvailableCouponActivities();
-        var activityList = Stream.concat(rechargeRightsActivityList.stream(), couponActivityList.stream()).toList();
+        // Java 21: 使用ArrayList替代Stream.concat提高可读性
+        var activityList = new ArrayList<Activity>(rechargeRightsActivityList);
+        activityList.addAll(couponActivityList);
 
         if (CommUtil.isNotEmptyAndNull(activityList)) {
             var station2ActivityListMap = activityStationService.lambdaQuery().eq(ActivityStation::getStatus, Activity.STATUS_进行中).list()

+ 156 - 0
service/src/main/java/com/kym/service/admin/impl/StationWhitelistConfigServiceImpl.java

@@ -0,0 +1,156 @@
+package com.kym.service.admin.impl;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.github.yulichang.wrapper.MPJLambdaWrapper;
+import com.kym.common.utils.CommUtil;
+import com.kym.common.utils.IDGenerator;
+import com.kym.entity.admin.Station;
+import com.kym.entity.admin.StationWhitelist;
+import com.kym.entity.admin.StationWhitelistConfig;
+import com.kym.entity.admin.vo.StationWhitelistConfigVo;
+import com.kym.entity.common.RedisKeys;
+import com.kym.mapper.admin.StationWhitelistConfigMapper;
+import com.kym.service.admin.StationService;
+import com.kym.service.admin.StationWhitelistConfigService;
+import com.kym.service.admin.StationWhitelistLogService;
+import com.kym.service.admin.StationWhitelistService;
+import jakarta.annotation.PostConstruct;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Service
+@DS("db-admin")
+public class StationWhitelistConfigServiceImpl extends MPJBaseServiceImpl<StationWhitelistConfigMapper, StationWhitelistConfig> implements StationWhitelistConfigService {
+
+    private static final long CACHE_EXPIRE_HOURS = 24;
+    private final IDGenerator idGenerator = new IDGenerator();
+    private final StationWhitelistConfigMapper stationWhitelistConfigMapper;
+    private final StationService stationService;
+    private final StationWhitelistService stationWhitelistService;
+    private final StationWhitelistLogService stationWhitelistLogService;
+    private final StringRedisTemplate redisTemplate;
+
+    public StationWhitelistConfigServiceImpl(StationWhitelistConfigMapper stationWhitelistConfigMapper,
+                                              StationService stationService,
+                                              StationWhitelistService stationWhitelistService,
+                                              StationWhitelistLogService stationWhitelistLogService,
+                                              StringRedisTemplate redisTemplate) {
+        this.stationWhitelistConfigMapper = stationWhitelistConfigMapper;
+        this.stationService = stationService;
+        this.stationWhitelistService = stationWhitelistService;
+        this.stationWhitelistLogService = stationWhitelistLogService;
+        this.redisTemplate = redisTemplate;
+    }
+
+    @PostConstruct
+    public void init() {
+        DynamicDataSourceContextHolder.push("db-admin");
+        refreshConfigCache();
+        DynamicDataSourceContextHolder.poll();
+    }
+
+    @Override
+    public List<StationWhitelistConfigVo> listConfig() {
+        return stationWhitelistConfigMapper.selectJoinList(StationWhitelistConfigVo.class,
+                new MPJLambdaWrapper<StationWhitelistConfig>()
+                        .selectAll(StationWhitelistConfig.class)
+                        .orderByDesc(StationWhitelistConfig::getCreateTime)
+        );
+    }
+
+    @Override
+    public StationWhitelistConfig getConfigByStationId(String stationId) {
+        return lambdaQuery()
+                .eq(StationWhitelistConfig::getStationId, stationId)
+                .one();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateConfig(String stationId, Integer enabled) {
+        CommUtil.assertsNonNull(stationId, "站点ID不能为空");
+
+        Station station = stationService.lambdaQuery()
+                .eq(Station::getStationId, stationId)
+                .one();
+        CommUtil.assertsNonNull(station, "站点不存在");
+
+        StationWhitelistConfig config = getConfigByStationId(stationId);
+        Long operatorId = StpUtil.getLoginIdAsLong();
+        String operatorName = StpUtil.getSession().getString("mobilePhone");
+
+        if (config == null) {
+            config = new StationWhitelistConfig();
+            config.setId(idGenerator.nextId());
+            config.setStationId(stationId);
+            config.setStationName(station.getStationName());
+            config.setEnabled(enabled);
+            config.setCreateBy(operatorId);
+            save(config);
+        } else {
+            lambdaUpdate()
+                    .set(StationWhitelistConfig::getEnabled, enabled)
+                    .eq(StationWhitelistConfig::getStationId, stationId)
+                    .update();
+        }
+
+        String key = RedisKeys.STATION_WHITELIST_CONFIG + stationId;
+        redisTemplate.opsForValue().set(key, String.valueOf(enabled), CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+
+        stationWhitelistLogService.logConfigChange(stationId, station.getStationName(), enabled, operatorId, operatorName);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteConfig(String stationId) {
+        CommUtil.assertsNonNull(stationId, "站点ID不能为空");
+        
+        lambdaUpdate().eq(StationWhitelistConfig::getStationId, stationId).remove();
+        
+        // 删除该站点的所有白名单用户
+        stationWhitelistService.lambdaUpdate()
+                .eq(StationWhitelist::getStationId, stationId)
+                .remove();
+        
+        String key = RedisKeys.STATION_WHITELIST_CONFIG + stationId;
+        redisTemplate.delete(key);
+        
+        // 清除白名单用户缓存
+        stationWhitelistService.refreshCache(stationId);
+        
+        Long operatorId = StpUtil.getLoginIdAsLong();
+        String operatorName = StpUtil.getSession().getString("mobilePhone");
+        // 这里可以记录日志,如果需要的话
+    }
+
+    @Override
+    public boolean isWhitelistEnabled(String stationId) {
+        String key = RedisKeys.STATION_WHITELIST_CONFIG + stationId;
+        String value = redisTemplate.opsForValue().get(key);
+        if (value != null) {
+            return "1".equals(value);
+        }
+        StationWhitelistConfig config = getConfigByStationId(stationId);
+        boolean enabled = config != null && config.getEnabled() == StationWhitelistConfig.ENABLED_是;
+        redisTemplate.opsForValue().set(key, String.valueOf(enabled ? 1 : 0), CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+        return enabled;
+    }
+
+    @Override
+    public void refreshConfigCache() {
+        List<StationWhitelistConfig> list = list();
+        for (StationWhitelistConfig config : list) {
+            String key = RedisKeys.STATION_WHITELIST_CONFIG + config.getStationId();
+            redisTemplate.opsForValue().set(key,
+                    String.valueOf(config.getEnabled()),
+                    CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+        }
+    }
+}

+ 100 - 0
service/src/main/java/com/kym/service/admin/impl/StationWhitelistLogServiceImpl.java

@@ -0,0 +1,100 @@
+package com.kym.service.admin.impl;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.kym.common.utils.IDGenerator;
+import com.kym.entity.admin.StationWhitelistLog;
+import com.kym.mapper.admin.StationWhitelistLogMapper;
+import com.kym.service.admin.StationWhitelistLogService;
+import org.springframework.stereotype.Service;
+
+@Service
+@DS("db-admin")
+public class StationWhitelistLogServiceImpl extends MPJBaseServiceImpl<StationWhitelistLogMapper, StationWhitelistLog> implements StationWhitelistLogService {
+
+    private final IDGenerator idGenerator = new IDGenerator();
+
+    @Override
+    public void logAdd(String stationId, String stationName, Long userId, String userPhone, Long operatorId, String operatorName) {
+        StationWhitelistLog log = new StationWhitelistLog();
+        log.setId(idGenerator.nextId());
+        log.setStationId(stationId);
+        log.setStationName(stationName);
+        log.setUserId(userId);
+        log.setUserPhone(userPhone);
+        log.setOperationType(StationWhitelistLog.OPERATION_TYPE_添加);
+        log.setOperationDesc("添加用户到白名单");
+        log.setOperatorId(operatorId);
+        log.setOperatorName(operatorName);
+        save(log);
+    }
+
+    @Override
+    public void logBatchAdd(String stationId, String stationName, int count, Long operatorId, String operatorName) {
+        StationWhitelistLog log = new StationWhitelistLog();
+        log.setId(idGenerator.nextId());
+        log.setStationId(stationId);
+        log.setStationName(stationName);
+        log.setOperationType(StationWhitelistLog.OPERATION_TYPE_批量添加);
+        log.setOperationDesc("批量添加" + count + "个用户到白名单");
+        log.setOperatorId(operatorId);
+        log.setOperatorName(operatorName);
+        save(log);
+    }
+
+    @Override
+    public void logRemove(String stationId, String stationName, Long userId, String userPhone, Long operatorId, String operatorName) {
+        StationWhitelistLog log = new StationWhitelistLog();
+        log.setId(idGenerator.nextId());
+        log.setStationId(stationId);
+        log.setStationName(stationName);
+        log.setUserId(userId);
+        log.setUserPhone(userPhone);
+        log.setOperationType(StationWhitelistLog.OPERATION_TYPE_删除);
+        log.setOperationDesc("从白名单移除用户");
+        log.setOperatorId(operatorId);
+        log.setOperatorName(operatorName);
+        save(log);
+    }
+
+    @Override
+    public void logBatchRemove(String stationId, String stationName, int count, Long operatorId, String operatorName) {
+        StationWhitelistLog log = new StationWhitelistLog();
+        log.setId(idGenerator.nextId());
+        log.setStationId(stationId);
+        log.setStationName(stationName);
+        log.setOperationType(StationWhitelistLog.OPERATION_TYPE_批量删除);
+        log.setOperationDesc("批量移除" + count + "个用户");
+        log.setOperatorId(operatorId);
+        log.setOperatorName(operatorName);
+        save(log);
+    }
+
+    @Override
+    public void logStatusChange(String stationId, String stationName, Long userId, String userPhone, Integer status, Long operatorId, String operatorName) {
+        StationWhitelistLog log = new StationWhitelistLog();
+        log.setId(idGenerator.nextId());
+        log.setStationId(stationId);
+        log.setStationName(stationName);
+        log.setUserId(userId);
+        log.setUserPhone(userPhone);
+        log.setOperationType(status == 1 ? StationWhitelistLog.OPERATION_TYPE_启用 : StationWhitelistLog.OPERATION_TYPE_禁用);
+        log.setOperationDesc(status == 1 ? "启用白名单用户" : "禁用白名单用户");
+        log.setOperatorId(operatorId);
+        log.setOperatorName(operatorName);
+        save(log);
+    }
+
+    @Override
+    public void logConfigChange(String stationId, String stationName, Integer enabled, Long operatorId, String operatorName) {
+        StationWhitelistLog log = new StationWhitelistLog();
+        log.setId(idGenerator.nextId());
+        log.setStationId(stationId);
+        log.setStationName(stationName);
+        log.setOperationType(StationWhitelistLog.OPERATION_TYPE_配置变更);
+        log.setOperationDesc(enabled == 1 ? "启用站点白名单功能" : "禁用站点白名单功能");
+        log.setOperatorId(operatorId);
+        log.setOperatorName(operatorName);
+        save(log);
+    }
+}

+ 307 - 0
service/src/main/java/com/kym/service/admin/impl/StationWhitelistServiceImpl.java

@@ -0,0 +1,307 @@
+package com.kym.service.admin.impl;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
+import com.github.pagehelper.PageHelper;
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.kym.common.exception.BusinessException;
+import com.kym.common.utils.CommUtil;
+import com.kym.common.utils.IDGenerator;
+import com.kym.entity.admin.Station;
+import com.kym.entity.admin.StationWhitelist;
+import com.kym.entity.admin.StationWhitelistLog;
+import com.kym.entity.admin.dto.BatchAddWhitelistDto;
+import com.kym.entity.admin.queryParams.StationWhitelistQueryParam;
+import com.kym.entity.admin.vo.StationWhitelistVo;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.common.RedisKeys;
+import com.kym.entity.miniapp.User;
+import com.kym.mapper.admin.StationWhitelistMapper;
+import com.kym.service.admin.StationService;
+import com.kym.service.admin.StationWhitelistLogService;
+import com.kym.service.admin.StationWhitelistService;
+import com.kym.service.miniapp.UserService;
+import jakarta.annotation.PostConstruct;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+@Service
+@DS("db-admin")
+public class StationWhitelistServiceImpl extends MPJBaseServiceImpl<StationWhitelistMapper, StationWhitelist> implements StationWhitelistService {
+
+    private static final long CACHE_EXPIRE_HOURS = 24;
+    private final IDGenerator idGenerator = new IDGenerator();
+    private final StationWhitelistMapper stationWhitelistMapper;
+    private final StationService stationService;
+    private final UserService userService;
+    private final StationWhitelistLogService stationWhitelistLogService;
+    private final StringRedisTemplate redisTemplate;
+
+    public StationWhitelistServiceImpl(StationWhitelistMapper stationWhitelistMapper,
+                                        StationService stationService,
+                                        UserService userService,
+                                        StationWhitelistLogService stationWhitelistLogService,
+                                        StringRedisTemplate redisTemplate) {
+        this.stationWhitelistMapper = stationWhitelistMapper;
+        this.stationService = stationService;
+        this.userService = userService;
+        this.stationWhitelistLogService = stationWhitelistLogService;
+        this.redisTemplate = redisTemplate;
+    }
+
+    @PostConstruct
+    public void init() {
+        DynamicDataSourceContextHolder.push("db-admin");
+        refreshAllCache();
+        DynamicDataSourceContextHolder.poll();
+    }
+
+    @Override
+    public PageBean<StationWhitelistVo> listWhitelist(StationWhitelistQueryParam param) {
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<StationWhitelistVo> list = stationWhitelistMapper.selectJoinList(StationWhitelistVo.class,
+                new com.github.yulichang.wrapper.MPJLambdaWrapper<StationWhitelist>()
+                        .selectAll(StationWhitelist.class)
+                        .select(User::getMobilePhone, User::getNickname)
+                        .leftJoin(User.class, User::getId, StationWhitelist::getUserId)
+                        .eq(!CommUtil.isEmptyOrNull(param.getStationId()), StationWhitelist::getStationId, param.getStationId())
+                        .like(!CommUtil.isEmptyOrNull(param.getUserPhone()), User::getMobilePhone, param.getUserPhone())
+                        .like(!CommUtil.isEmptyOrNull(param.getUserNickname()), User::getNickname, param.getUserNickname())
+                        .eq(!CommUtil.isEmptyOrNull(param.getStatus()), StationWhitelist::getStatus, param.getStatus())
+                        .orderByDesc(StationWhitelist::getCreateTime)
+        );
+        return new PageBean<>(list);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void addWhitelist(StationWhitelist whitelist) {
+        CommUtil.assertsNonNull(whitelist.getStationId(), "站点ID不能为空");
+        CommUtil.asserts(whitelist.getUserId() != null && whitelist.getUserId() > 0, "用户ID不能为空");
+
+        Station station = stationService.lambdaQuery()
+                .eq(Station::getStationId, whitelist.getStationId())
+                .one();
+        CommUtil.assertsNonNull(station, "站点不存在");
+
+        User user = userService.getById(whitelist.getUserId());
+        CommUtil.assertsNonNull(user, "用户不存在");
+
+        StationWhitelist exists = lambdaQuery()
+                .eq(StationWhitelist::getStationId, whitelist.getStationId())
+                .eq(StationWhitelist::getUserId, whitelist.getUserId())
+                .one();
+        if (exists != null) {
+            throw new BusinessException("用户已在该站点白名单中");
+        }
+
+        whitelist.setId(idGenerator.nextId());
+        whitelist.setStationName(station.getStationName());
+        whitelist.setUserPhone(user.getMobilePhone());
+        whitelist.setUserNickname(user.getNickname());
+        whitelist.setStatus(StationWhitelist.STATUS_启用);
+        whitelist.setCreateBy(StpUtil.getLoginIdAsLong());
+
+        save(whitelist);
+
+        refreshCache(whitelist.getStationId());
+
+        stationWhitelistLogService.logAdd(whitelist.getStationId(), whitelist.getStationName(),
+                whitelist.getUserId(), whitelist.getUserPhone(),
+                StpUtil.getLoginIdAsLong(), StpUtil.getSession().getString("mobilePhone"));
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void batchAddWhitelist(BatchAddWhitelistDto dto) {
+        CommUtil.assertsNonNull(dto.getStationId(), "站点ID不能为空");
+        CommUtil.asserts(dto.getUserIds() != null && !dto.getUserIds().isEmpty(), "用户ID列表不能为空");
+
+        Station station = stationService.lambdaQuery()
+                .eq(Station::getStationId, dto.getStationId())
+                .one();
+        CommUtil.assertsNonNull(station, "站点不存在");
+
+        List<StationWhitelist> existingList = lambdaQuery()
+                .eq(StationWhitelist::getStationId, dto.getStationId())
+                .in(StationWhitelist::getUserId, dto.getUserIds())
+                .list();
+        Set<Long> existingUserIds = existingList.stream()
+                .map(StationWhitelist::getUserId)
+                .collect(Collectors.toSet());
+
+        List<User> users = userService.listByIds(dto.getUserIds());
+        Map<Long, User> userMap = users.stream()
+                .collect(Collectors.toMap(User::getId, u -> u));
+
+        Long operatorId = StpUtil.getLoginIdAsLong();
+        String operatorName = StpUtil.getSession().getString("mobilePhone");
+
+        List<StationWhitelist> toSave = new ArrayList<>();
+        for (Long userId : dto.getUserIds()) {
+            if (existingUserIds.contains(userId)) {
+                continue;
+            }
+            User user = userMap.get(userId);
+            if (user == null) {
+                continue;
+            }
+            StationWhitelist whitelist = new StationWhitelist();
+            whitelist.setId(idGenerator.nextId());
+            whitelist.setStationId(dto.getStationId());
+            whitelist.setStationName(station.getStationName());
+            whitelist.setUserId(userId);
+            whitelist.setUserPhone(user.getMobilePhone());
+            whitelist.setUserNickname(user.getNickname());
+            whitelist.setStatus(StationWhitelist.STATUS_启用);
+            whitelist.setRemark(dto.getRemark());
+            whitelist.setCreateBy(operatorId);
+            toSave.add(whitelist);
+        }
+
+        if (!toSave.isEmpty()) {
+            saveBatch(toSave);
+            refreshCache(dto.getStationId());
+            stationWhitelistLogService.logBatchAdd(dto.getStationId(), station.getStationName(),
+                    toSave.size(), operatorId, operatorName);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void removeWhitelist(Long id) {
+        StationWhitelist whitelist = getById(id);
+        CommUtil.assertsNonNull(whitelist, "白名单记录不存在");
+
+        removeById(id);
+        refreshCache(whitelist.getStationId());
+
+        stationWhitelistLogService.logRemove(whitelist.getStationId(), whitelist.getStationName(),
+                whitelist.getUserId(), whitelist.getUserPhone(),
+                StpUtil.getLoginIdAsLong(), StpUtil.getSession().getString("mobilePhone"));
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void batchRemoveWhitelist(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return;
+        }
+        List<StationWhitelist> list = listByIds(ids);
+        if (list.isEmpty()) {
+            return;
+        }
+
+        removeByIds(ids);
+
+        Map<String, List<StationWhitelist>> groupByStation = list.stream()
+                .collect(Collectors.groupingBy(StationWhitelist::getStationId));
+        for (String stationId : groupByStation.keySet()) {
+            refreshCache(stationId);
+        }
+
+        stationWhitelistLogService.logBatchRemove(list.get(0).getStationId(), list.get(0).getStationName(),
+                list.size(), StpUtil.getLoginIdAsLong(), StpUtil.getSession().getString("mobilePhone"));
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateStatus(Long id, Integer status) {
+        StationWhitelist whitelist = getById(id);
+        CommUtil.assertsNonNull(whitelist, "白名单记录不存在");
+
+        lambdaUpdate()
+                .set(StationWhitelist::getStatus, status)
+                .eq(StationWhitelist::getId, id)
+                .update();
+
+        refreshCache(whitelist.getStationId());
+
+        stationWhitelistLogService.logStatusChange(whitelist.getStationId(), whitelist.getStationName(),
+                whitelist.getUserId(), whitelist.getUserPhone(), status,
+                StpUtil.getLoginIdAsLong(), StpUtil.getSession().getString("mobilePhone"));
+    }
+
+    @Override
+    public boolean isInWhitelist(String stationId, Long userId) {
+        String key = RedisKeys.STATION_WHITELIST_USERS + stationId;
+        if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
+            return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(key, String.valueOf(userId)));
+        }
+        
+        // 缓存不存在,重建缓存
+        refreshCache(stationId);
+        
+        // 再次检查缓存
+        if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
+            return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(key, String.valueOf(userId)));
+        }
+        
+        return false;
+    }
+
+    @Override
+    public List<Long> getWhitelistUserIds(String stationId) {
+        String key = RedisKeys.STATION_WHITELIST_USERS + stationId;
+        Set<String> members = redisTemplate.opsForSet().members(key);
+        if (members != null && !members.isEmpty()) {
+            return members.stream()
+                    .map(Long::valueOf)
+                    .collect(Collectors.toList());
+        }
+        List<StationWhitelist> list = lambdaQuery()
+                .eq(StationWhitelist::getStationId, stationId)
+                .eq(StationWhitelist::getStatus, StationWhitelist.STATUS_启用)
+                .list();
+        return list.stream()
+                .map(StationWhitelist::getUserId)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public void refreshCache(String stationId) {
+        String key = RedisKeys.STATION_WHITELIST_USERS + stationId;
+        redisTemplate.delete(key);
+
+        List<StationWhitelist> list = lambdaQuery()
+                .eq(StationWhitelist::getStationId, stationId)
+                .eq(StationWhitelist::getStatus, StationWhitelist.STATUS_启用)
+                .list();
+
+        if (!list.isEmpty()) {
+            String[] userIds = list.stream()
+                    .map(w -> String.valueOf(w.getUserId()))
+                    .toArray(String[]::new);
+            redisTemplate.opsForSet().add(key, userIds);
+            redisTemplate.expire(key, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+        }
+    }
+
+    @Override
+    public void refreshAllCache() {
+        List<StationWhitelist> allList = lambdaQuery()
+                .eq(StationWhitelist::getStatus, StationWhitelist.STATUS_启用)
+                .list();
+
+        Map<String, List<StationWhitelist>> groupByStation = allList.stream()
+                .collect(Collectors.groupingBy(StationWhitelist::getStationId));
+
+        for (Map.Entry<String, List<StationWhitelist>> entry : groupByStation.entrySet()) {
+            String stationId = entry.getKey();
+            List<StationWhitelist> list = entry.getValue();
+            String key = RedisKeys.STATION_WHITELIST_USERS + stationId;
+            redisTemplate.delete(key);
+            String[] userIds = list.stream()
+                    .map(w -> String.valueOf(w.getUserId()))
+                    .toArray(String[]::new);
+            redisTemplate.opsForSet().add(key, userIds);
+            redisTemplate.expire(key, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+        }
+    }
+}

+ 243 - 44
service/src/main/java/com/kym/service/cache/KymCache.java

@@ -15,6 +15,8 @@ import com.kym.service.admin.ConnectorInfoService;
 import com.kym.service.admin.EquipmentRelationService;
 import com.kym.service.admin.PlatformService;
 import com.kym.service.admin.StationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.context.event.ApplicationStartedEvent;
 import org.springframework.context.ApplicationListener;
@@ -23,8 +25,8 @@ import org.springframework.stereotype.Component;
 
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 /**
@@ -35,90 +37,220 @@ import java.util.stream.Collectors;
 public enum KymCache {
     INSTANCE;
 
+    private static final Logger log = LoggerFactory.getLogger(KymCache.class);
 
-    private static ConcurrentHashMap<String, String> CONNECTOR_ID_SHORT_ID_MAPPING = new ConcurrentHashMap<>();
+    /**
+     * 缓存过期时间:24小时
+     */
+    private static final long CACHE_EXPIRE_HOURS = 24;
 
+    /**
+     * 带数据源切换的数据库查询(用于缓存miss时按需加载)
+     *
+     * @param supplier 数据库查询逻辑
+     * @param <T>      返回类型
+     * @return 查询结果
+     */
+    private <T> T executeWithDataSource(Supplier<T> supplier) {
+        DynamicDataSourceContextHolder.push("db-admin");
+        try {
+            return supplier.get();
+        } finally {
+            DynamicDataSourceContextHolder.poll();
+        }
+    }
 
     /**
-     * 获取枪头状态
+     * 获取枪头状态(带空值保护和缓存miss时按需加载)
      *
-     * @param connectorId
-     * @return
+     * @param connectorId 充电接口ID
+     * @return 状态值,如果不存在返回null
      */
     public Integer getConnectorStatus(String connectorId) {
-        return Integer.valueOf(Objects.requireNonNull(KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_STATUS + connectorId)));
+        if (connectorId == null) {
+            return null;
+        }
+        try {
+            String value = KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_STATUS + connectorId);
+            if (value == null) {
+                // 缓存miss,从数据库加载
+                Integer status = loadConnectorStatusFromDb(connectorId);
+                if (status != null) {
+                    // 重新缓存
+                    KymCacheInjector.redisTemplate.opsForValue()
+                            .set(RedisKeys.CONNECTOR_ID_TO_STATUS + connectorId, String.valueOf(status), CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+                }
+                return status;
+            }
+            if ("-1".equals(value)) {
+                return null;
+            }
+            return Integer.valueOf(value);
+        } catch (Exception e) {
+            log.warn("获取枪头状态失败,connectorId: {}", connectorId, e);
+            return null;
+        }
+    }
+
+    /**
+     * 从数据库加载枪头状态
+     */
+    private Integer loadConnectorStatusFromDb(String connectorId) {
+        return executeWithDataSource(() -> {
+            ConnectorInfoService service = SpringUtil.getBean(ConnectorInfoService.class);
+            ConnectorInfo info = service.lambdaQuery().eq(ConnectorInfo::getConnectorId, connectorId).one();
+            return info != null ? info.getStatus() : null;
+        });
     }
 
     /**
-     * 缓存枪头状态
+     * 缓存枪头状态(带过期时间)
      *
-     * @param map
+     * @param map 状态映射
      */
     public void putConnectorId2Status(Map<String, Integer> map) {
-        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue().set(RedisKeys.CONNECTOR_ID_TO_STATUS + k, String.valueOf(v)));
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.CONNECTOR_ID_TO_STATUS + k, String.valueOf(v), CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
     }
 
 
     /**
-     * 缓存枪头短编号
+     * 缓存枪头短编号(带过期时间,移除本地Map缓存)
      *
-     * @param map
+     * @param map 短编号映射
      */
     public void putConnectorId2ShortId(Map<String, String> map) {
-        CONNECTOR_ID_SHORT_ID_MAPPING.putAll(map);
-        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue().set(RedisKeys.CONNECTOR_ID_TO_SHORT_ID + k, v));
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.CONNECTOR_ID_TO_SHORT_ID + k, v, CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
     }
 
     /**
-     * 通过设备编号或者枪头编号获取设备短编号
+     * 缓存短编号到connectorId的反向索引(带过期时间)
      *
-     * @param id
-     * @return
+     * @param map 短编号 -> connectorId 映射
+     */
+    public void putShortId2ConnectorId(Map<String, String> map) {
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.SHORT_ID_TO_CONNECTOR_ID + k, v, CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
+    }
+
+    /**
+     * 通过设备编号或者枪头编号获取设备短编号(带缓存miss时按需加载)
+     *
+     * @param id 设备编号或枪头编号
+     * @return 短编号
      */
     public String getShortIdByEquipmentIdOrConnectorId(String id) {
         var connectorId = getConnectorId(id);
-        return KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_SHORT_ID + connectorId);
+        if (connectorId == null) {
+            return null;
+        }
+        String shortId = KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_SHORT_ID + connectorId);
+        if (shortId == null) {
+            // 缓存miss,从数据库加载
+            EquipmentRelation relation = loadEquipmentRelationByConnectorId(connectorId);
+            if (relation != null) {
+                shortId = relation.getShortId();
+                // 重新缓存
+                KymCacheInjector.redisTemplate.opsForValue()
+                        .set(RedisKeys.CONNECTOR_ID_TO_SHORT_ID + connectorId, shortId, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+                // 同时缓存反向索引
+                KymCacheInjector.redisTemplate.opsForValue()
+                        .set(RedisKeys.SHORT_ID_TO_CONNECTOR_ID + shortId, connectorId, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+            }
+        }
+        return shortId;
+    }
+
+    /**
+     * 从数据库加载设备关系
+     */
+    private EquipmentRelation loadEquipmentRelationByConnectorId(String connectorId) {
+        return executeWithDataSource(() -> {
+            EquipmentRelationService service = SpringUtil.getBean(EquipmentRelationService.class);
+            return service.getByConnectorId(connectorId);
+        });
     }
 
 
     public void putConnectorId2StationId(Map<String, String> map) {
-        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue().set(RedisKeys.CONNECTOR_ID_TO_STATION_ID + k, v));
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.CONNECTOR_ID_TO_STATION_ID + k, v, CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
     }
 
     /**
-     * 通过设备编号或者枪头编号获取站点id
+     * 通过设备编号或者枪头编号获取站点id(带缓存miss时按需加载)
      *
-     * @param id
-     * @return
+     * @param id 设备编号或枪头编号
+     * @return 站点ID
      */
     public String getStationIdByEquipmentIdOrConnectorId(String id) {
         var connectorId = getConnectorId(id);
-        return KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_STATION_ID + connectorId);
+        if (connectorId == null) {
+            return null;
+        }
+        String stationId = KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_STATION_ID + connectorId);
+        if (stationId == null) {
+            // 缓存miss,从数据库加载
+            EquipmentRelation relation = loadEquipmentRelationByConnectorId(connectorId);
+            if (relation != null) {
+                stationId = relation.getStationId();
+                // 重新缓存
+                KymCacheInjector.redisTemplate.opsForValue()
+                        .set(RedisKeys.CONNECTOR_ID_TO_STATION_ID + connectorId, stationId, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+            }
+        }
+        return stationId;
     }
 
     public void putStationId2Name(Map<String, String> map) {
-        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue().set(RedisKeys.STATION_ID_TO_NAME + k, v));
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.STATION_ID_TO_NAME + k, v, CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
     }
 
     /**
-     * 通过站点id获取站点名称
+     * 通过站点id获取站点名称(带缓存miss时按需加载)
      *
-     * @param stationId
-     * @return
+     * @param stationId 站点ID
+     * @return 站点名称
      */
     public String getStationNameById(String stationId) {
-        return KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.STATION_ID_TO_NAME + stationId);
+        if (stationId == null) {
+            return null;
+        }
+        String stationName = KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.STATION_ID_TO_NAME + stationId);
+        if (stationName == null) {
+            // 缓存miss,从数据库加载
+            stationName = loadStationNameFromDb(stationId);
+            if (stationName != null) {
+                // 重新缓存
+                KymCacheInjector.redisTemplate.opsForValue()
+                        .set(RedisKeys.STATION_ID_TO_NAME + stationId, stationName, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+            }
+        }
+        return stationName;
+    }
+
+    /**
+     * 从数据库加载站点名称
+     */
+    private String loadStationNameFromDb(String stationId) {
+        return executeWithDataSource(() -> {
+            StationService service = SpringUtil.getBean(StationService.class);
+            Station station = service.lambdaQuery().eq(Station::getStationId, stationId).one();
+            return station != null ? station.getStationName() : null;
+        });
     }
 
     /**
-     * 通过充电接口id获取站点名称
+     * 通过充电接口id获取站点名称(复用getStationNameById的缓存miss加载逻辑)
      *
-     * @param connectorId
-     * @return
+     * @param connectorId 充电接口ID
+     * @return 站点名称
      */
     public String getStationNameByConnectorId(String connectorId) {
         var stationId = getStationIdByEquipmentIdOrConnectorId(connectorId);
-        return KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.STATION_ID_TO_NAME + stationId);
+        return getStationNameById(stationId);
     }
 
     /**
@@ -147,16 +279,18 @@ public enum KymCache {
     }
 
     public void putConnectorId2ParkingNo(Map<String, String> map) {
-        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue().set(RedisKeys.CONNECTOR_ID_TO_PARKING_NO + k, v));
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.CONNECTOR_ID_TO_PARKING_NO + k, v, CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
     }
 
     /**
-     * 站点费率
+     * 站点费率(带过期时间)
      *
-     * @param map
+     * @param map 费率映射
      */
     public void putStationId2PolicyInfo(Map<String, List<PlatformPolicyInfoVo>> map) {
-        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue().set(RedisKeys.STATION_ID_POLICY_INFO + k, JSONObject.toJSONString(v)));
+        map.forEach((k, v) -> KymCacheInjector.redisTemplate.opsForValue()
+                .set(RedisKeys.STATION_ID_POLICY_INFO + k, JSONObject.toJSONString(v), CACHE_EXPIRE_HOURS, TimeUnit.HOURS));
     }
 
     /**
@@ -171,30 +305,92 @@ public enum KymCache {
     }
 
     /**
-     * @param id
-     * @return
+     * 通过设备编号或枪头编号获取停车位号(带缓存miss时按需加载)
+     *
+     * @param id 设备编号或枪头编号
+     * @return 停车位号
      */
     public String getParkNoByEquipmentIdOrConnectorId(String id) {
         var connectorId = getConnectorId(id);
-        return KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_PARKING_NO + connectorId);
+        if (connectorId == null) {
+            return null;
+        }
+        String parkingNo = KymCacheInjector.redisTemplate.opsForValue().get(RedisKeys.CONNECTOR_ID_TO_PARKING_NO + connectorId);
+        if (parkingNo == null) {
+            // 缓存miss,从数据库加载
+            EquipmentRelation relation = loadEquipmentRelationByConnectorId(connectorId);
+            if (relation != null) {
+                parkingNo = relation.getParkingNo();
+                // 重新缓存
+                KymCacheInjector.redisTemplate.opsForValue()
+                        .set(RedisKeys.CONNECTOR_ID_TO_PARKING_NO + connectorId, parkingNo, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+            }
+        }
+        return parkingNo;
     }
 
     /**
-     * 将equipmentId,connectorId,shortId转化成17为的connectorId
+     * 将equipmentId,connectorId,shortId转化成17位的connectorId
+     * 从Redis查找短编号对应的完整connectorId
      *
-     * @param id
-     * @return
+     * @param id 设备ID(可能是17位、16位或6位短编号)
+     * @return 17位的connectorId
      */
     public String getConnectorId(String id) {
+        if (id == null) {
+            return null;
+        }
         return switch (id.length()) {
             case 17 -> id;
             case 16 -> id.concat("1");
-            case 6 ->
-                    CONNECTOR_ID_SHORT_ID_MAPPING.entrySet().stream().filter(entry -> id.equals(entry.getValue())).map(Map.Entry::getKey).findFirst().get();
+            case 6 -> {
+                // 从Redis反向查找短编号对应的connectorId
+                String connectorId = getConnectorIdByShortId(id);
+                yield connectorId;
+            }
             default -> null;
         };
     }
 
+    /**
+     * 通过短编号从Redis查找对应的connectorId(带缓存miss时按需加载)
+     *
+     * @param shortId 短编号
+     * @return connectorId
+     */
+    private String getConnectorIdByShortId(String shortId) {
+        // 从Redis反向索引中查找
+        String connectorId = KymCacheInjector.redisTemplate.opsForValue()
+                .get(RedisKeys.SHORT_ID_TO_CONNECTOR_ID + shortId);
+        if (connectorId != null) {
+            return connectorId;
+        }
+        // 缓存miss,从数据库加载
+        connectorId = loadConnectorIdByShortId(shortId);
+        if (connectorId != null) {
+            // 重新缓存反向索引
+            KymCacheInjector.redisTemplate.opsForValue()
+                    .set(RedisKeys.SHORT_ID_TO_CONNECTOR_ID + shortId, connectorId, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+            // 同时缓存正向索引
+            KymCacheInjector.redisTemplate.opsForValue()
+                    .set(RedisKeys.CONNECTOR_ID_TO_SHORT_ID + connectorId, shortId, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
+            return connectorId;
+        }
+        log.warn("未找到短编号对应的connectorId,shortId: {}", shortId);
+        return null;
+    }
+
+    /**
+     * 从数据库通过短编号加载connectorId
+     */
+    private String loadConnectorIdByShortId(String shortId) {
+        return executeWithDataSource(() -> {
+            EquipmentRelationService service = SpringUtil.getBean(EquipmentRelationService.class);
+            EquipmentRelation relation = service.getByShortId(shortId);
+            return relation != null ? relation.getConnectorId() : null;
+        });
+    }
+
 
     @Component
     public static class KymCacheInjector implements ApplicationListener<ApplicationStartedEvent> {
@@ -227,6 +423,9 @@ public enum KymCache {
 
             var connectorId2ShortId = equipmentRelations.stream().collect(Collectors.toMap(EquipmentRelation::getConnectorId, EquipmentRelation::getShortId));
             KymCache.INSTANCE.putConnectorId2ShortId(connectorId2ShortId);
+            // 同时建立反向索引:短编号 -> connectorId
+            var shortId2ConnectorId = equipmentRelations.stream().collect(Collectors.toMap(EquipmentRelation::getShortId, EquipmentRelation::getConnectorId));
+            KymCache.INSTANCE.putShortId2ConnectorId(shortId2ConnectorId);
 
             var connector2Station = equipmentRelations.stream().collect(Collectors.toMap(EquipmentRelation::getConnectorId, EquipmentRelation::getStationId));
             KymCache.INSTANCE.putConnectorId2StationId(connector2Station);

+ 2 - 0
service/src/main/java/com/kym/service/miniapp/UserService.java

@@ -28,4 +28,6 @@ public interface UserService extends MPJBaseService<User> {
     R<?> wxLogin(WxLoginParams params);
 
     PageBean<CustomUserVo> listCustomUser(CommonQueryParam params);
+
+    PageBean<User> listSimpleUser(CommonQueryParam params);
 }

+ 20 - 2
service/src/main/java/com/kym/service/miniapp/impl/ChargeServiceImpl.java

@@ -20,6 +20,8 @@ import com.kym.entity.platform.response.PlatformBusinessPolicy;
 import com.kym.service.admin.ConnectorInfoService;
 import com.kym.service.admin.EquipmentInfoService;
 import com.kym.service.admin.EquipmentRelationService;
+import com.kym.service.admin.StationWhitelistConfigService;
+import com.kym.service.admin.StationWhitelistService;
 import com.kym.service.cache.KymCache;
 import com.kym.service.jobs.DelayService;
 import com.kym.service.miniapp.*;
@@ -61,6 +63,8 @@ public class ChargeServiceImpl implements ChargeService {
     private final PlatformApiService platformApiService;
     private final DelayService<DelayChargeOrder> startDelayService;
     private final DelayService<DelayChargeOrder> stopDelayService;
+    private final StationWhitelistService stationWhitelistService;
+    private final StationWhitelistConfigService stationWhitelistConfigService;
 
     public ChargeServiceImpl(EquipmentRelationService equipmentRelationService,
                              EquipmentInfoService equipmentInfoService,
@@ -74,6 +78,8 @@ public class ChargeServiceImpl implements ChargeService {
                              PlatformApiService platformApiService,
                              @Qualifier("StartChargeDelayJob") @Lazy DelayService<DelayChargeOrder> startDelayService,
                              @Qualifier("StopChargeDelayJob") @Lazy DelayService<DelayChargeOrder> stopDelayService,
+                             StationWhitelistService stationWhitelistService,
+                             StationWhitelistConfigService stationWhitelistConfigService,
                              StringRedisTemplate redisTemplate) {
         this.equipmentRelationService = equipmentRelationService;
         this.equipmentInfoService = equipmentInfoService;
@@ -87,6 +93,8 @@ public class ChargeServiceImpl implements ChargeService {
         this.platformApiService = platformApiService;
         this.startDelayService = startDelayService;
         this.stopDelayService = stopDelayService;
+        this.stationWhitelistService = stationWhitelistService;
+        this.stationWhitelistConfigService = stationWhitelistConfigService;
         this.redisTemplate = redisTemplate;
     }
 
@@ -202,7 +210,7 @@ public class ChargeServiceImpl implements ChargeService {
         connectorId = connectorId2StationId.get("connectorId");
         var stationId = connectorId2StationId.get("stationId");
         LOGGER.info("用户:{},站点:{},设备:{}请求充电", userId, stationId, connectorId);
-        var account = checkCharge(userId, connectorId, isBooking, startTime);
+        var account = checkCharge(userId, connectorId, stationId, isBooking, startTime);
 
         // 是否有之前预约充电创建的订单记录,有则直接用,没有则创建
         ChargeOrder order = chargeOrderService.lambdaQuery()
@@ -312,11 +320,12 @@ public class ChargeServiceImpl implements ChargeService {
      *
      * @param userId
      * @param connectorId
+     * @param stationId
      * @param isBooking
      * @param startTime
      * @return
      */
-    private Account checkCharge(Long userId, String connectorId, Boolean isBooking, LocalDateTime startTime) {
+    private Account checkCharge(Long userId, String connectorId, String stationId, Boolean isBooking, LocalDateTime startTime) {
         if (CommUtil.isEmptyOrNull(connectorId)) {
             throw new BusinessException("请输入正确的设备编号");
         }
@@ -366,6 +375,15 @@ public class ChargeServiceImpl implements ChargeService {
             throw new BusinessException(ResponseEnum.INSUFFICIENT_USER_BALANCE);
         }
 
+        // 白名单验证
+        if (stationId != null && stationWhitelistConfigService.isWhitelistEnabled(stationId)) {
+            if (!stationWhitelistService.isInWhitelist(stationId, userId)) {
+                LOGGER.warn("用户:{}不在站点:{}的白名单中,拒绝充电", userId, stationId);
+                throw new BusinessException(ResponseEnum.USER_NOT_IN_WHITELIST);
+            }
+            LOGGER.info("用户:{}在站点:{}的白名单中,验证通过", userId, stationId);
+        }
+
         return account;
     }
 

+ 11 - 0
service/src/main/java/com/kym/service/miniapp/impl/UserServiceImpl.java

@@ -298,5 +298,16 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
         return page;
     }
 
+    @Override
+    public PageBean<User> listSimpleUser(CommonQueryParam params) {
+        PageHelper.startPage(params.getPageNum(), params.getPageSize());
+        var list = lambdaQuery()
+                .select(User::getId, User::getMobilePhone, User::getNickname, User::getUsername)
+                .like(!CommUtil.isEmptyOrNull(params.getMobilePhone()), User::getMobilePhone, params.getMobilePhone())
+                .orderByDesc(User::getCreateTime)
+                .list();
+        return new PageBean<>(list);
+    }
+
 
 }

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

@@ -6,7 +6,6 @@ import com.kym.entity.wechat.*;
 import com.wechat.pay.java.service.payments.jsapi.model.PrepayWithRequestPaymentResponse;
 import com.wechat.pay.java.service.refund.model.Refund;
 import jakarta.servlet.http.HttpServletRequest;
-import lombok.SneakyThrows;
 import org.springframework.http.ResponseEntity;
 
 import java.io.IOException;
@@ -22,9 +21,10 @@ public interface WxPayService {
 
     void wxRefund(long refundLogId);
 
+    void manualConfirmRefund(long refundLogId);
+
     Refund queryByOutRefundNo(String outRefundNo);
 
-    @SneakyThrows
     ResponseEntity<Object> wxRefundNotify(HttpServletRequest request);
 
     PrepayWithRequestPaymentResponse wxPay(JSONObject rechargeAmount);

+ 89 - 0
service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -492,6 +492,95 @@ public class WxPayServiceImpl implements WxPayService {
     }
 
 
+    /**
+     * 手动确认退款(针对超过365天的订单)
+     *
+     * @param refundLogId 退款记录ID
+     */
+    @DS("db-miniapp")
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void manualConfirmRefund(long refundLogId) {
+        // 通过退款申请id获取退款申请记录
+        var refundLog = refundLogService.getById(refundLogId);
+        if (refundLog == null) {
+            throw new BusinessException("退款记录不存在");
+        }
+
+        // 检查退款状态,只能处理已申请状态的退款
+        if (!RefundLog.STATUS_退款已申请.equals(refundLog.getStatus())) {
+            throw new BusinessException("退款状态不正确,只能处理已申请状态的退款");
+        }
+
+        // 验证是否超过退款时限(365天)
+        var payLog = payLogService.lambdaQuery()
+                .eq(PayLog::getOutTradeNo, refundLog.getOutTradeNo())
+                .eq(PayLog::getTradeState, PayLog.STATUS_充值成功)
+                .one();
+        
+        if (payLog == null) {
+            throw new BusinessException("找不到对应的支付记录");
+        }
+
+        // 检查是否超过365天退款时限
+        LocalDateTime paySuccessTime = payLog.getSuccessTime();
+        boolean isOverdue = paySuccessTime.plusDays(365).isBefore(LocalDateTime.now());
+        
+        if (!isOverdue) {
+            throw new BusinessException("该订单未超过365天退款时限,应使用正常退款流程");
+        }
+
+        // 钱包流水
+        var walletDetail = new WalletDetail()
+                .setType(WalletDetail.TYPE_提现)
+                .setStatus(WalletDetail.STATUS_待确认)
+                .setUserId(refundLog.getUserId())
+                .setAmount(refundLog.getRefund())
+                .setOrderNo(refundLog.getOutRefundNo());
+        walletDetailService.save(walletDetail);
+
+        // 执行与微信退款成功后相同的业务逻辑
+        processRefundSuccess(refundLog, walletDetail);
+        
+        // 记录操作人员信息
+        refundLogService.lambdaUpdate()
+                .set(RefundLog::getAdminUserId, StpUtil.getLoginIdAsLong())
+                .set(RefundLog::getAdminUsername, StpUtil.getSession().getString("username"))
+                .eq(RefundLog::getId, refundLogId)
+                .update();
+        
+        LOGGER.info("手动确认退款完成,退款记录ID:{},操作人员:{}", refundLogId, StpUtil.getSession().getString("username"));
+    }
+
+    /**
+     * 处理退款成功的公共逻辑
+     *
+     * @param refundLog 退款记录
+     * @param walletDetail 钱包流水
+     */
+    private void processRefundSuccess(RefundLog refundLog, WalletDetail walletDetail) {
+        // 更新退款记录状态为退款成功
+        LocalDateTime successTime = LocalDateTime.now();
+        refundLogService.lambdaUpdate()
+                .set(RefundLog::getStatus, RefundLog.STATUS_退款成功)
+                .set(RefundLog::getSuccessTime, successTime)
+                .set(RefundLog::getUserReceivedAccount, "线下退款")
+                .eq(RefundLog::getId, refundLog.getId())
+                .update();
+
+        // 冻结金额扣减此次(退款金额+优惠金额),优惠金额字段减去申请退款时的优惠金额
+        accountService.lambdaUpdate().setSql("frozen_amount = (frozen_amount - (%d + %d)) , discount_amount = (discount_amount - %d)"
+                        .formatted(refundLog.getRefund(), refundLog.getDiscountAmount(), refundLog.getDiscountAmount()))
+                .eq(Account::getUserId, refundLog.getUserId()).update();
+
+        // 更新资金流水
+        walletDetailService.lambdaUpdate()
+                .set(WalletDetail::getStatus, WalletDetail.STATUS_已确认)
+                .set(WalletDetail::getTransactionTime, successTime)
+                .set(WalletDetail::getAmount, refundLog.getRefund())
+                .eq(WalletDetail::getId, walletDetail.getId()).update();
+    }
+
     /**
      * 处理退款
      *