Prechádzať zdrojové kódy

充值配置分组化重构:group + item 两级模型

- 新增 t_recharge_config_group 表,station_id 关联站点(NULL=默认组),每站一个分组
- t_recharge_config 移除 station_id,新增 group_id 关联分组
- 新增 RechargeConfigGroupService,核心方法:getByStationId/getDefaultGroup/createGroupForStation
- listByStationId 通过分组联查返回扁平列表,miniapp API 响应格式不变
- wxNotify 回调改用 getByStationAndAmount,按归属站+金额查找,修复同金额不同组赠款错误
- 站点创建时调用 createGroupForStation 自动建组+复制默认配置项
- admin-web 改为分组列表+展开配置项的两级布局,拆分为 groupDialog + itemDialog
- 管理后台新增分组级 CRUD 接口(/group/*),保留兼容旧版 /list /listAll
- 数据迁移脚本:自动按现有数据创建分组、回填 group_id、删除旧列

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 4 dní pred
rodič
commit
66fcfb6396

+ 123 - 0
admin-web/src/views/admin/platform/rechargeConfig/groupDialog.vue

@@ -0,0 +1,123 @@
+<style scoped lang="scss">
+
+</style>
+<template>
+  <div class="system-dialog-container">
+    <el-dialog
+        :title="state.dialog.title"
+        v-model="state.dialog.isShowDialog"
+        width="500px"
+        draggable
+        destroy-on-close
+        :close-on-click-modal="false"
+        align-center>
+      <el-form
+          :model="state.ruleForm"
+          label-position="top"
+          ref="formRef"
+          size="default">
+        <el-form-item label="关联站点" prop="stationId">
+          <el-select-v2
+              v-model="state.ruleForm.stationId"
+              :options="state.stationOptions"
+              placeholder="留空则为平台默认分组"
+              clearable
+              style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item v-if="false" label="分组名称">
+          <el-input
+              v-model="state.ruleForm.name"
+              placeholder="可选,如「VIP套餐」"
+              clearable>
+          </el-input>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <span 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>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="GroupDialog">
+import { reactive, ref } from 'vue';
+import { Msg } from "/@/utils/message";
+import { $body } from "/@/utils/request";
+import u from '/@/utils/u'
+
+const emit = defineEmits(['refresh']);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: {
+    id: null as number | null,
+    stationId: null as string | null,
+    name: '' as string,
+  },
+  btnLoading: false,
+  dialog: {
+    isShowDialog: false,
+    type: '',
+    title: '',
+    submitTxt: '',
+  },
+  stationOptions: [] as Array<{ value: string; 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;
+
+  // 加载已有分组的站点列表(避免重复创建)
+  $body('/washStation/list', { pageNum: 1, pageSize: 200 }).then((res: any) => {
+    state.stationOptions = (res.list || []).map((s: any) => ({
+      value: s.stationId,
+      label: s.stationName + ' (' + s.stationId + ')',
+    }));
+  });
+
+  if (action !== 'add' && row) {
+    state.ruleForm.id = row.id;
+    state.ruleForm.stationId = row.stationId || null;
+    state.ruleForm.name = row.name || '';
+  }
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => {
+  onClose();
+};
+
+const onSubmit = () => {
+  state.btnLoading = true;
+  const payload = {
+    id: state.ruleForm.id,
+    stationId: state.ruleForm.stationId || null,
+    name: state.ruleForm.name || null,
+  };
+  const url = !!state.ruleForm.id ? "/rechargeConfig/group/modify" : "/rechargeConfig/group/add";
+  $body(url, payload).then(() => {
+    state.btnLoading = false;
+    Msg.message('操作成功');
+    onClose();
+    emit('refresh');
+  }).catch(() => {
+    state.btnLoading = false;
+    Msg.message('操作失败', 'error');
+  });
+};
+
+defineExpose({ open });
+</script>

+ 140 - 99
admin-web/src/views/admin/platform/rechargeConfig/index.vue

@@ -1,146 +1,187 @@
 <style scoped lang="scss">
-.system-container {
+.group-card {
+  margin-bottom: 16px;
 
-  :deep(.el-card__body) {
+  .group-header {
     display: flex;
-    flex-direction: column;
+    align-items: center;
     justify-content: space-between;
-    flex: 1;
-    overflow: auto;
+    padding: 12px 16px;
+    background-color: var(--el-fill-color-light);
+    border-radius: 6px 6px 0 0;
+    border: 1px solid var(--el-border-color-light);
+    border-bottom: none;
 
-    .el-table {
-      flex: 1;
+    .group-info {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+
+      .group-title {
+        font-weight: 600;
+        font-size: 15px;
+      }
     }
 
+    .group-actions {
+      display: flex;
+      gap: 8px;
+    }
   }
-}
 
-.page-content {
-  margin-bottom: 20px;
+  .item-table-wrap {
+    border: 1px solid var(--el-border-color-light);
+    border-top: none;
+    border-radius: 0 0 6px 6px;
+    padding: 0;
+  }
 }
 </style>
 <template>
   <div class="system-container layout-padding">
     <el-card shadow="hover" class="layout-padding-auto">
-      <div class="page-search mb10">
-        <el-select-v2
-            v-model="state.filterStationId"
-            :options="state.stationOptions"
-            placeholder="按站点筛选"
-            clearable
-            style="width: 240px"
-            @change="loadData"
-        />
-        <el-button v-auth="'rechargeConfig.add'" size="default" plain type="success" class="ml10" @click="handleRowClick('add', null)">
+      <div class="page-search mb10" style="display: flex; align-items: center; gap: 10px;">
+        <el-button v-auth="'rechargeConfig.add'" size="default" plain type="success" @click="handleGroupAdd">
           <SvgIcon name="ele-FolderAdd"/>
-          新增
+          新增默认分组
         </el-button>
       </div>
 
-      <el-table
-          border
-          stripe
-          :height="state.tableData.height"
-          highlight-current-row
-          row-key="id"
-          :data="state.tableData.data"
-          v-loading="state.tableData.loading">
-        <template #empty>
-          <el-empty></el-empty>
-        </template>
-
-        <el-table-column label="站点" prop="stationId" width="160">
-          <template #default="{ row }">
-            <el-tag v-if="row.stationId" type="success">{{ row.stationId }}</el-tag>
-            <el-tag v-else type="info">平台默认</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="充值金额" prop="rechargeAmount" width="140">
-          <template #default="{ row }">
-            {{ u.fmt.fmtMoney(row.rechargeAmount) }}
-          </template>
-        </el-table-column>
-        <el-table-column label="赠款金额" prop="grantsAmount" width="140">
-          <template #default="{ row }">
-            {{ u.fmt.fmtMoney(row.grantsAmount) }}
-          </template>
-        </el-table-column>
-        <el-table-column label="标签" prop="label" min-width="120">
-          <template #default="{ row }">
-            {{ row.label || '-' }}
-          </template>
-        </el-table-column>
-        <el-table-column label="创建时间" prop="createTime" width="180">
-          <template #default="{ row }">
-            {{ u.fmt.fmtDateTime(row.createTime) }}
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" prop="action" width="160" align="center" fixed="right">
-          <template #default="{ row }">
-            <el-button v-auth="'rechargeConfig.modify'" type="warning" size="small" text @click="handleRowClick('edit', row)">编辑</el-button>
-            <el-button v-auth="'rechargeConfig.remove'" type="danger" size="small" text @click="handleRowDelete(row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
+      <div v-loading="state.loading">
+        <el-empty v-if="!state.loading && state.groups.length === 0" description="暂无充值配置分组" />
+
+        <div v-for="g in state.groups" :key="g.group.id" class="group-card">
+          <div class="group-header">
+            <div class="group-info">
+              <span class="group-title">
+                <el-tag v-if="g.group.stationId" type="success" effect="light">{{ g.group.stationId }}</el-tag>
+                <el-tag v-else type="primary" effect="light">平台默认配置</el-tag>
+              </span>
+              <span v-if="g.group.name" style="color: var(--el-text-color-secondary); font-size: 13px;">{{ g.group.name }}</span>
+            </div>
+            <div class="group-actions">
+              <el-button v-auth="'rechargeConfig.add'" size="small" type="primary" plain @click="handleItemAdd(g.group.id)">+ 配置项</el-button>
+              <el-button v-if="g.group.stationId" v-auth="'rechargeConfig.remove'" size="small" type="danger" plain @click="handleGroupRemove(g.group)">删除分组</el-button>
+            </div>
+          </div>
+          <div class="item-table-wrap">
+            <el-table
+                border
+                :data="g.items"
+                size="small"
+                v-loading="g.itemsLoading">
+              <template #empty>
+                <el-empty :image-size="40" description="暂无配置项,请新增" />
+              </template>
+              <el-table-column label="充值金额" prop="rechargeAmount" width="140">
+                <template #default="{ row }">
+                  {{ u.fmt.fmtMoney(row.rechargeAmount) }}
+                </template>
+              </el-table-column>
+              <el-table-column label="赠款金额" prop="grantsAmount" width="140">
+                <template #default="{ row }">
+                  {{ u.fmt.fmtMoney(row.grantsAmount) }}
+                </template>
+              </el-table-column>
+              <el-table-column label="标签" prop="label" min-width="120">
+                <template #default="{ row }">
+                  {{ row.label || '-' }}
+                </template>
+              </el-table-column>
+              <el-table-column label="操作" prop="action" width="160" align="center" fixed="right">
+                <template #default="{ row }">
+                  <el-button v-auth="'rechargeConfig.modify'" type="warning" size="small" text @click="handleItemEdit(g.group.id, row)">编辑</el-button>
+                  <el-button v-auth="'rechargeConfig.remove'" type="danger" size="small" text @click="handleItemRemove(row)">删除</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+        </div>
+      </div>
     </el-card>
   </div>
-  <RechargeConfigDialog ref="rechargeConfigDialogRef" @refresh="loadData"/>
+  <GroupDialog ref="groupDialogRef" @refresh="loadData" />
+  <ItemDialog ref="itemDialogRef" @refresh="loadData" />
 </template>
 
 <script setup lang="ts" name="AdminRechargeConfig">
-import { defineAsyncComponent, reactive, onMounted, ref, nextTick } from 'vue';
-import { $body, $get } from "/@/utils/request";
+import { defineAsyncComponent, reactive, onMounted, ref } from 'vue';
+import { $get } from "/@/utils/request";
 import u from '/@/utils/u'
 import { Msg } from "/@/utils/message";
 
-const RechargeConfigDialog = defineAsyncComponent(() => import("/@/views/admin/platform/rechargeConfig/dialog.vue"));
+const GroupDialog = defineAsyncComponent(() => import("./groupDialog.vue"));
+const ItemDialog = defineAsyncComponent(() => import("./itemDialog.vue"));
 
-const rechargeConfigDialogRef = ref();
+const groupDialogRef = ref();
+const itemDialogRef = ref();
+
+interface GroupRow {
+  group: any;
+  items: any[];
+  itemsLoading: boolean;
+}
 
 const state = reactive({
-  filterStationId: null as string | null,
-  stationOptions: [] as Array<{ value: string; label: string }>,
-  tableData: {
-    height: 500,
-    data: [] as Array<any>,
-    loading: false,
-  },
+  loading: false,
+  groups: [] as GroupRow[],
 });
 
 onMounted(() => {
-  loadStations();
   loadData();
 });
 
-const loadStations = () => {
-  $body('/washStation/list', { pageNum: 1, pageSize: 200 }).then((res: any) => {
-    state.stationOptions = [
-      { value: '__all__', label: '全部' },
-      ...(res.list || []).map((s: any) => ({ value: s.stationId, label: s.stationName + ' (' + s.stationId + ')' })),
-    ];
+const loadData = () => {
+  state.loading = true;
+  $get('/rechargeConfig/group/list').then((groups: any) => {
+    state.groups = (groups || []).map((g: any) => ({ group: g, items: [], itemsLoading: false }));
+    state.groups.forEach((g) => loadItems(g));
+    state.loading = false;
+  }).catch(() => {
+    state.loading = false;
   });
 };
 
-const loadData = () => {
-  state.tableData.loading = true;
-  const url = state.filterStationId && state.filterStationId !== '__all__'
-    ? `/rechargeConfig/list?stationId=${state.filterStationId}`
-    : '/rechargeConfig/listAll';
-  $get(url).then((res: any) => {
-    state.tableData.data = res || [];
-    state.tableData.loading = false;
+const loadItems = (g: GroupRow) => {
+  g.itemsLoading = true;
+  $get('/rechargeConfig/item/list', { groupId: g.group.id }).then((items: any) => {
+    g.items = items || [];
+    g.itemsLoading = false;
   }).catch(() => {
-    state.tableData.loading = false;
+    g.itemsLoading = false;
   });
 };
 
-const handleRowClick = (type: string, row: any) => {
-  rechargeConfigDialogRef.value.open(type, row);
+// ==================== 分组操作 ====================
+
+const handleGroupAdd = () => {
+  groupDialogRef.value.open('add', null);
+};
+
+const handleGroupRemove = (group: any) => {
+  Msg.confirm('此操作将永久删除该分组及其所有配置项,是否继续?').then(() => {
+    $get(`/rechargeConfig/group/remove/${group.id}`).then(() => {
+      Msg.message("删除成功", 'success');
+      loadData();
+    }).catch(() => {
+      Msg.message("删除失败", 'error');
+    });
+  });
+};
+
+// ==================== 配置项操作 ====================
+
+const handleItemAdd = (groupId: number) => {
+  itemDialogRef.value.open('add', { groupId });
+};
+
+const handleItemEdit = (groupId: number, row: any) => {
+  itemDialogRef.value.open('edit', { ...row, groupId });
 };
 
-const handleRowDelete = (row: any) => {
-  Msg.confirm(`此操作将永久删除该充值配置,是否继续?`).then(() => {
-    $get(`/rechargeConfig/remove/${row.id}`).then(() => {
+const handleItemRemove = (row: any) => {
+  Msg.confirm('此操作将永久删除该配置项,是否继续?').then(() => {
+    $get(`/rechargeConfig/item/remove/${row.id}`).then(() => {
       Msg.message("删除成功", 'success');
       loadData();
     }).catch(() => {

+ 15 - 35
admin-web/src/views/admin/platform/rechargeConfig/dialog.vue → admin-web/src/views/admin/platform/rechargeConfig/itemDialog.vue

@@ -6,7 +6,7 @@
     <el-dialog
         :title="state.dialog.title"
         v-model="state.dialog.isShowDialog"
-        width="600px"
+        width="500px"
         draggable
         destroy-on-close
         :close-on-click-modal="false"
@@ -16,19 +16,8 @@
           :rules="state.rules"
           label-position="top"
           ref="formRef"
-          size="default"
-          label-width="100px"
-          class="mt5">
-        <el-form-item label="关联站点" prop="stationId">
-          <el-select-v2
-              v-model="state.ruleForm.stationId"
-              :options="state.stationOptions"
-              placeholder="留空则为平台默认配置"
-              clearable
-              style="width: 100%"
-          />
-        </el-form-item>
-        <el-form-item label="充值金额(元)" prop="rechargeAmount">
+          size="default">
+        <el-form-item label="充值金额(元)" prop="rechargeAmountYuan">
           <el-input-number
               v-model="state.ruleForm.rechargeAmountYuan"
               placeholder="充值金额"
@@ -37,7 +26,7 @@
               style="width: 100%">
           </el-input-number>
         </el-form-item>
-        <el-form-item label="赠款金额(元)" prop="grantsAmount">
+        <el-form-item label="赠款金额(元)" prop="grantsAmountYuan">
           <el-input-number
               v-model="state.ruleForm.grantsAmountYuan"
               placeholder="赠款金额(0表示不赠送)"
@@ -65,10 +54,10 @@
   </div>
 </template>
 
-<script setup lang="ts" name="RechargeConfigDialog">
-import { reactive, onMounted, ref } from 'vue';
+<script setup lang="ts" name="ItemDialog">
+import { reactive, ref } from 'vue';
 import { Msg } from "/@/utils/message";
-import { $body, $get } from "/@/utils/request";
+import { $body } from "/@/utils/request";
 import u from '/@/utils/u'
 
 const emit = defineEmits(['refresh']);
@@ -77,7 +66,7 @@ const formRef = ref();
 const initState = () => ({
   ruleForm: {
     id: null as number | null,
-    stationId: null as string | null,
+    groupId: null as number | null,
     rechargeAmountYuan: 0,
     grantsAmountYuan: 0,
     label: '',
@@ -92,33 +81,24 @@ const initState = () => ({
   rules: {
     rechargeAmountYuan: [u.validator.required],
   },
-  stationOptions: [] as Array<{ value: string; label: string }>,
 })
 
 const state = reactive(initState());
 
-onMounted(() => {
-  // 加载站点列表供选择
-  $body('/washStation/list', { pageNum: 1, pageSize: 200 }).then((res: any) => {
-    state.stationOptions = (res.list || []).map((s: any) => ({
-      value: s.stationId,
-      label: s.stationName + ' (' + s.stationId + ')',
-    }));
-  });
-});
-
 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.title = u.dialog.actions[action].title + "『配置项』"
+  state.dialog.submitTxt = u.dialog.actions[action].btn + "『配置项』"
   state.dialog.isShowDialog = true;
   if (action !== 'add' && row) {
     state.ruleForm = {
       id: row.id,
-      stationId: row.stationId || null,
+      groupId: row.groupId,
       rechargeAmountYuan: (row.rechargeAmount || 0) / 100,
       grantsAmountYuan: (row.grantsAmount || 0) / 100,
       label: row.label || '',
     };
+  } else if (row) {
+    state.ruleForm.groupId = row.groupId;
   }
 };
 
@@ -137,12 +117,12 @@ const onSubmit = () => {
       state.btnLoading = true;
       const payload = {
         id: state.ruleForm.id,
-        stationId: state.ruleForm.stationId || null,
+        groupId: state.ruleForm.groupId,
         rechargeAmount: Math.round(state.ruleForm.rechargeAmountYuan * 100),
         grantsAmount: Math.round(state.ruleForm.grantsAmountYuan * 100),
         label: state.ruleForm.label || null,
       };
-      const url = !!state.ruleForm.id ? "/rechargeConfig/modify" : "/rechargeConfig/add";
+      const url = !!state.ruleForm.id ? "/rechargeConfig/item/modify" : "/rechargeConfig/item/add";
       $body(url, payload).then(() => {
         state.btnLoading = false;
         Msg.message('操作成功');

+ 94 - 28
car-wash-admin/src/main/java/com/kym/admin/controller/RechargeConfigController.java

@@ -4,11 +4,13 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
 import com.kym.common.R;
 import com.kym.common.annotation.SysLog;
 import com.kym.entity.RechargeConfig;
+import com.kym.entity.RechargeConfigGroup;
+import com.kym.service.RechargeConfigGroupService;
 import com.kym.service.RechargeConfigService;
 import org.springframework.web.bind.annotation.*;
 
 /**
- * 充值配置管理(支持按站点维度
+ * 充值配置管理(分组 + 配置项两级管理
  *
  * @author skyline
  */
@@ -16,60 +18,124 @@ import org.springframework.web.bind.annotation.*;
 @RequestMapping("/rechargeConfig")
 public class RechargeConfigController {
 
-    private final RechargeConfigService rechargeConfigService;
+    private final RechargeConfigService itemService;
+    private final RechargeConfigGroupService groupService;
 
-    public RechargeConfigController(RechargeConfigService rechargeConfigService) {
-        this.rechargeConfigService = rechargeConfigService;
+    public RechargeConfigController(RechargeConfigService itemService, RechargeConfigGroupService groupService) {
+        this.itemService = itemService;
+        this.groupService = groupService;
     }
 
+    // ==================== 分组管理 ====================
+
     /**
-     * 充值配置列表
+     * 所有分组列表
      */
     @SaCheckPermission("rechargeConfig.list")
-    @GetMapping("/list")
-    R<?> list(@RequestParam(required = false) String stationId) {
-        return R.success(rechargeConfigService.listByStationId(stationId));
+    @GetMapping("/group/list")
+    R<?> groupList() {
+        return R.success(groupService.list());
     }
 
     /**
-     * 查询全部充值配置
+     * 新增分组
      */
-    @SaCheckPermission("rechargeConfig.list")
-    @GetMapping("/listAll")
-    R<?> listAll() {
-        return R.success(rechargeConfigService.list());
+    @SaCheckPermission("rechargeConfig.add")
+    @SysLog("新增充值配置分组")
+    @PostMapping("/group/add")
+    R<?> groupAdd(@RequestBody RechargeConfigGroup group) {
+        if (group.getStationId() != null) {
+            var existing = groupService.getByStationId(group.getStationId());
+            if (existing != null) {
+                return R.fail("该站点已有配置分组");
+            }
+        }
+        groupService.save(group);
+        return R.success();
+    }
+
+    /**
+     * 修改分组信息
+     */
+    @SaCheckPermission("rechargeConfig.modify")
+    @SysLog("修改充值配置分组")
+    @PostMapping("/group/modify")
+    R<?> groupModify(@RequestBody RechargeConfigGroup group) {
+        groupService.updateById(group);
+        return R.success();
+    }
+
+    /**
+     * 删除分组
+     */
+    @SaCheckPermission("rechargeConfig.remove")
+    @SysLog("删除充值配置分组")
+    @GetMapping("/group/remove/{id}")
+    R<?> groupRemove(@PathVariable long id) {
+        groupService.removeById(id);
+        return R.success();
+    }
+
+    // ==================== 配置项管理 ====================
+
+    /**
+     * 查询某分组下的配置项列表
+     */
+    @GetMapping("/item/list")
+    R<?> itemList(@RequestParam Long groupId) {
+        return R.success(itemService.listByGroupId(groupId));
     }
 
     /**
-     * 新增充值配置
+     * 新增配置
      */
     @SaCheckPermission("rechargeConfig.add")
-    @SysLog("新增充值配置")
-    @PostMapping("/add")
-    R<?> add(@RequestBody RechargeConfig config) {
-        rechargeConfigService.save(config);
+    @SysLog("新增充值配置")
+    @PostMapping("/item/add")
+    R<?> itemAdd(@RequestBody RechargeConfig config) {
+        itemService.save(config);
         return R.success();
     }
 
     /**
-     * 修改充值配置
+     * 修改配置
      */
     @SaCheckPermission("rechargeConfig.modify")
-    @SysLog("修改充值配置")
-    @PostMapping("/modify")
-    R<?> modify(@RequestBody RechargeConfig config) {
-        rechargeConfigService.updateById(config);
+    @SysLog("修改充值配置")
+    @PostMapping("/item/modify")
+    R<?> itemModify(@RequestBody RechargeConfig config) {
+        itemService.updateById(config);
         return R.success();
     }
 
     /**
-     * 删除充值配置
+     * 删除配置
      */
     @SaCheckPermission("rechargeConfig.remove")
-    @SysLog("删除充值配置")
-    @GetMapping("/remove/{id}")
-    R<?> remove(@PathVariable long id) {
-        rechargeConfigService.removeById(id);
+    @SysLog("删除充值配置")
+    @GetMapping("/item/remove/{id}")
+    R<?> itemRemove(@PathVariable long id) {
+        itemService.removeById(id);
         return R.success();
     }
+
+    // ==================== 兼容旧接口 ====================
+
+    /**
+     * 按站点筛选配置项(返回扁平列表,兼容小程序查询)
+     */
+    @SaCheckPermission("rechargeConfig.list")
+    @GetMapping("/list")
+    R<?> list(@RequestParam(required = false) String stationId) {
+        return R.success(itemService.listByStationId(stationId));
+    }
+
+    /**
+     * 全部配置项
+     */
+    @SaCheckPermission("rechargeConfig.list")
+    @GetMapping("/listAll")
+    R<?> listAll() {
+        return R.success(itemService.list());
+    }
 }

+ 6 - 9
car-wash-entity/src/main/java/com/kym/entity/RechargeConfig.java

@@ -1,15 +1,12 @@
 package com.kym.entity;
 
 import com.baomidou.mybatisplus.annotation.TableName;
-import com.kym.entity.BaseEntity;
 import java.io.Serializable;
 import lombok.Getter;
 import lombok.Setter;
 
 /**
- * <p>
- * 支付日志
- * </p>
+ * 充值配置项 — 属于某个配置组(RechargeConfigGroup),定义单个充值档位的金额和赠款
  *
  * @author skyline
  * @since 2024-11-15
@@ -21,6 +18,11 @@ public class RechargeConfig extends BaseEntity {
 
     private static final long serialVersionUID = 1L;
 
+    /**
+     * 所属分组ID
+     */
+    private Long groupId;
+
     /**
      * 充值金额(分)
      */
@@ -35,9 +37,4 @@ public class RechargeConfig extends BaseEntity {
      * 文字标签(用于悬浮在充值金额旁)
      */
     private String label;
-
-    /**
-     * 站点ID,NULL表示平台默认配置(新站点自动使用)
-     */
-    private String stationId;
 }

+ 29 - 0
car-wash-entity/src/main/java/com/kym/entity/RechargeConfigGroup.java

@@ -0,0 +1,29 @@
+package com.kym.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 充值配置分组 — 每个站点(或平台默认)拥有一个配置组,组内包含多条充值金额配置项
+ *
+ * @author skyline
+ * @since 2026-05-26
+ */
+@Getter
+@Setter
+@TableName("t_recharge_config_group")
+public class RechargeConfigGroup extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 分组名称(可选)
+     */
+    private String name;
+
+    /**
+     * 站点ID,NULL表示平台默认配置组
+     */
+    private String stationId;
+}

+ 49 - 0
car-wash-entity/src/main/resources/sql/v2_settlement.sql

@@ -140,3 +140,52 @@ WHERE NOT EXISTS (SELECT 1 FROM `t_permission` WHERE `value` = 'rechargeConfig.r
 
 -- 注意:执行完上述权限记录插入后,需在管理后台「角色管理」中为对应角色勾选充值配置权限
 -- 角色权限存储在 t_role.permissions 字段(JSON格式),无法直接通过SQL安全合并
+
+-- ====================================================
+-- 9. V2.2 充值配置分组化(2026-05-26)
+-- ====================================================
+
+-- 9a. 创建配置分组表
+CREATE TABLE IF NOT EXISTS `t_recharge_config_group` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `name` VARCHAR(64) DEFAULT NULL COMMENT '分组名称',
+    `station_id` VARCHAR(64) DEFAULT NULL COMMENT '站点ID,NULL表示平台默认配置',
+    `company_id` BIGINT DEFAULT NULL COMMENT '公司(租户)ID',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_station_id` (`station_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值配置分组表';
+
+-- 9b. 为已有的 station_id(含NULL默认值)创建分组
+INSERT INTO `t_recharge_config_group` (`station_id`, `name`, `create_time`, `update_time`)
+SELECT DISTINCT `station_id`, NULL, NOW(), NOW()
+FROM `t_recharge_config`
+WHERE NOT EXISTS (
+    SELECT 1 FROM `t_recharge_config_group` g
+    WHERE (g.`station_id` <=> `t_recharge_config`.`station_id`)
+);
+
+-- 9c. t_recharge_config 新增 group_id 列
+ALTER TABLE `t_recharge_config`
+    ADD COLUMN `group_id` BIGINT DEFAULT NULL COMMENT '所属分组ID'
+    AFTER `id`;
+
+-- 9d. 回填 group_id
+UPDATE `t_recharge_config` c
+    INNER JOIN `t_recharge_config_group` g
+        ON (c.`station_id` <=> g.`station_id`)
+SET c.`group_id` = g.`id`;
+
+-- 9e. group_id 设为 NOT NULL
+ALTER TABLE `t_recharge_config`
+    MODIFY COLUMN `group_id` BIGINT NOT NULL COMMENT '所属分组ID';
+
+-- 9f. 删除旧 station_id 列
+ALTER TABLE `t_recharge_config`
+    DROP COLUMN `station_id`;
+
+-- 9g. 组内金额唯一约束
+ALTER TABLE `t_recharge_config`
+    ADD INDEX `idx_group_id` (`group_id`),
+    ADD UNIQUE INDEX `uk_group_recharge_amount` (`group_id`, `recharge_amount`);

+ 13 - 0
car-wash-mapper/src/main/java/com/kym/mapper/RechargeConfigGroupMapper.java

@@ -0,0 +1,13 @@
+package com.kym.mapper;
+
+import com.kym.entity.RechargeConfigGroup;
+import com.kym.service.mybatisplus.MyBaseMapper;
+
+/**
+ * 充值配置分组 Mapper
+ *
+ * @author skyline
+ * @since 2026-05-26
+ */
+public interface RechargeConfigGroupMapper extends MyBaseMapper<RechargeConfigGroup> {
+}

+ 28 - 0
car-wash-service/src/main/java/com/kym/service/RechargeConfigGroupService.java

@@ -0,0 +1,28 @@
+package com.kym.service;
+
+import com.kym.entity.RechargeConfigGroup;
+import com.kym.service.mybatisplus.MyBaseService;
+
+/**
+ * 充值配置分组服务
+ *
+ * @author skyline
+ * @since 2026-05-26
+ */
+public interface RechargeConfigGroupService extends MyBaseService<RechargeConfigGroup> {
+
+    /**
+     * 根据站点ID获取分组
+     */
+    RechargeConfigGroup getByStationId(String stationId);
+
+    /**
+     * 获取平台默认分组(station_id IS NULL)
+     */
+    RechargeConfigGroup getDefaultGroup();
+
+    /**
+     * 为站点创建分组并复制默认分组的配置项
+     */
+    RechargeConfigGroup createGroupForStation(String stationId);
+}

+ 17 - 8
car-wash-service/src/main/java/com/kym/service/RechargeConfigService.java

@@ -3,22 +3,31 @@ package com.kym.service;
 import com.kym.entity.RechargeConfig;
 import com.kym.service.mybatisplus.MyBaseService;
 
+import java.util.List;
+
 /**
- * <p>
- * 支付日志 服务类
- * </p>
+ * 充值配置项服务
  *
  * @author skyline
  * @since 2024-11-15
  */
-import java.util.List;
-
 public interface RechargeConfigService extends MyBaseService<RechargeConfig> {
 
-    RechargeConfig getRechargeConfigByAmount(Integer amount);
-
     /**
-     * 根据站点查询充值配置,优先返回站点专属配置,若无则返回默认配置
+     * 根据站点查询配置项(通过分组联查),优先返回站点专属配置,若无则返回默认配置。
+     * 返回扁平列表,API响应格式不变。
      */
     List<RechargeConfig> listByStationId(String stationId);
+
+    /**
+     * 根据用户归属站点+充值金额查找配置项。
+     * 先查站点专属分组 → 回退默认分组 → 组内匹配金额。
+     * 用于充值回调场景(wxNotify),确保同金额不同组时赠款金额正确。
+     */
+    RechargeConfig getByStationAndAmount(String stationId, Integer amount);
+
+    /**
+     * 根据分组ID查询该分组下的所有配置项
+     */
+    List<RechargeConfig> listByGroupId(Long groupId);
 }

+ 65 - 0
car-wash-service/src/main/java/com/kym/service/impl/RechargeConfigGroupServiceImpl.java

@@ -0,0 +1,65 @@
+package com.kym.service.impl;
+
+import com.kym.entity.RechargeConfig;
+import com.kym.entity.RechargeConfigGroup;
+import com.kym.mapper.RechargeConfigGroupMapper;
+import com.kym.service.RechargeConfigGroupService;
+import com.kym.service.RechargeConfigService;
+import com.kym.service.mybatisplus.MyBaseServiceImpl;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 充值配置分组服务实现
+ *
+ * @author skyline
+ * @since 2026-05-26
+ */
+@Service
+public class RechargeConfigGroupServiceImpl
+        extends MyBaseServiceImpl<RechargeConfigGroupMapper, RechargeConfigGroup>
+        implements RechargeConfigGroupService {
+
+    private final RechargeConfigService itemService;
+
+    public RechargeConfigGroupServiceImpl(RechargeConfigService itemService) {
+        this.itemService = itemService;
+    }
+
+    @Override
+    public RechargeConfigGroup getByStationId(String stationId) {
+        return lambdaQuery().eq(RechargeConfigGroup::getStationId, stationId).one();
+    }
+
+    @Override
+    public RechargeConfigGroup getDefaultGroup() {
+        return lambdaQuery().isNull(RechargeConfigGroup::getStationId).one();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public RechargeConfigGroup createGroupForStation(String stationId) {
+        RechargeConfigGroup group = new RechargeConfigGroup();
+        group.setStationId(stationId);
+        save(group);
+
+        RechargeConfigGroup defaultGroup = getDefaultGroup();
+        if (defaultGroup != null) {
+            List<RechargeConfig> defaultItems = itemService.listByGroupId(defaultGroup.getId());
+            if (!defaultItems.isEmpty()) {
+                var newItems = defaultItems.stream().map(c -> {
+                    var item = new RechargeConfig();
+                    item.setGroupId(group.getId());
+                    item.setRechargeAmount(c.getRechargeAmount());
+                    item.setGrantsAmount(c.getGrantsAmount());
+                    item.setLabel(c.getLabel());
+                    return item;
+                }).toList();
+                itemService.saveBatch(newItems);
+            }
+        }
+        return group;
+    }
+}

+ 51 - 14
car-wash-service/src/main/java/com/kym/service/impl/RechargeConfigServiceImpl.java

@@ -1,16 +1,17 @@
 package com.kym.service.impl;
 
 import com.kym.entity.RechargeConfig;
+import com.kym.entity.RechargeConfigGroup;
 import com.kym.mapper.RechargeConfigMapper;
+import com.kym.service.RechargeConfigGroupService;
 import com.kym.service.RechargeConfigService;
 import com.kym.service.mybatisplus.MyBaseServiceImpl;
-import java.util.List;
 import org.springframework.stereotype.Service;
 
+import java.util.List;
+
 /**
- * <p>
- * 支付日志 服务实现类
- * </p>
+ * 充值配置项服务实现
  *
  * @author skyline
  * @since 2024-11-15
@@ -18,20 +19,56 @@ import org.springframework.stereotype.Service;
 @Service
 public class RechargeConfigServiceImpl extends MyBaseServiceImpl<RechargeConfigMapper, RechargeConfig> implements RechargeConfigService {
 
-    @Override
-    public RechargeConfig getRechargeConfigByAmount(Integer amount) {
-        return lambdaQuery().eq(RechargeConfig::getRechargeAmount, amount).one();
+    private final RechargeConfigGroupService groupService;
+
+    public RechargeConfigServiceImpl(RechargeConfigGroupService groupService) {
+        this.groupService = groupService;
     }
 
     @Override
     public List<RechargeConfig> listByStationId(String stationId) {
-        // 优先返回站点专属配置,若无则回退到默认配置
-        List<RechargeConfig> stationConfigs = lambdaQuery()
-                .eq(RechargeConfig::getStationId, stationId)
-                .list();
-        if (!stationConfigs.isEmpty()) {
-            return stationConfigs;
+        // 优先站点专属分组
+        RechargeConfigGroup group = groupService.getByStationId(stationId);
+        if (group != null) {
+            var items = lambdaQuery().eq(RechargeConfig::getGroupId, group.getId()).list();
+            if (!items.isEmpty()) {
+                return items;
+            }
+        }
+        // 回退默认分组
+        group = groupService.getDefaultGroup();
+        if (group != null) {
+            return lambdaQuery().eq(RechargeConfig::getGroupId, group.getId()).list();
+        }
+        return List.of();
+    }
+
+    @Override
+    public RechargeConfig getByStationAndAmount(String stationId, Integer amount) {
+        // 优先站点专属分组
+        RechargeConfigGroup group = groupService.getByStationId(stationId);
+        if (group != null) {
+            var config = lambdaQuery()
+                    .eq(RechargeConfig::getGroupId, group.getId())
+                    .eq(RechargeConfig::getRechargeAmount, amount)
+                    .one();
+            if (config != null) {
+                return config;
+            }
+        }
+        // 回退默认分组
+        group = groupService.getDefaultGroup();
+        if (group != null) {
+            return lambdaQuery()
+                    .eq(RechargeConfig::getGroupId, group.getId())
+                    .eq(RechargeConfig::getRechargeAmount, amount)
+                    .one();
         }
-        return lambdaQuery().isNull(RechargeConfig::getStationId).list();
+        return null;
+    }
+
+    @Override
+    public List<RechargeConfig> listByGroupId(Long groupId) {
+        return lambdaQuery().eq(RechargeConfig::getGroupId, groupId).list();
     }
 }

+ 6 - 19
car-wash-service/src/main/java/com/kym/service/impl/WashStationServiceImpl.java

@@ -3,13 +3,12 @@ package com.kym.service.impl;
 import com.github.pagehelper.PageHelper;
 import com.kym.common.utils.CommUtil;
 import com.kym.entity.common.PageBean;
-import com.kym.entity.RechargeConfig;
 import com.kym.entity.WashDevice;
 import com.kym.entity.WashStation;
 import com.kym.entity.queryParams.StationQueryParams;
 import com.kym.entity.vo.WashStationVo;
 import com.kym.mapper.WashStationMapper;
-import com.kym.service.RechargeConfigService;
+import com.kym.service.RechargeConfigGroupService;
 import com.kym.service.WashDeviceService;
 import com.kym.service.WashStationService;
 import com.kym.service.cache.KymCache;
@@ -39,11 +38,11 @@ public class WashStationServiceImpl extends MyBaseServiceImpl<WashStationMapper,
     }
 
     private final WashDeviceService washDeviceService;
-    private final RechargeConfigService rechargeConfigService;
+    private final RechargeConfigGroupService rechargeConfigGroupService;
 
-    public WashStationServiceImpl(WashDeviceService washDeviceService, RechargeConfigService rechargeConfigService) {
+    public WashStationServiceImpl(WashDeviceService washDeviceService, RechargeConfigGroupService rechargeConfigGroupService) {
         this.washDeviceService = washDeviceService;
-        this.rechargeConfigService = rechargeConfigService;
+        this.rechargeConfigGroupService = rechargeConfigGroupService;
     }
 
 
@@ -55,20 +54,8 @@ public class WashStationServiceImpl extends MyBaseServiceImpl<WashStationMapper,
         CommUtil.asserts(count == 0, "站点已存在");
         save(station);
 
-        // 自动使用默认充值配置:将平台默认配置复制一份关联到新站点
-        var defaultConfigs = rechargeConfigService.lambdaQuery()
-                .isNull(RechargeConfig::getStationId).list();
-        if (!defaultConfigs.isEmpty()) {
-            var stationConfigs = defaultConfigs.stream().map(c -> {
-                var config = new RechargeConfig();
-                config.setRechargeAmount(c.getRechargeAmount());
-                config.setGrantsAmount(c.getGrantsAmount());
-                config.setLabel(c.getLabel());
-                config.setStationId(station.getStationId());
-                return config;
-            }).toList();
-            rechargeConfigService.saveBatch(stationConfigs);
-        }
+        // 自动创建站点专属充值配置分组(复制平台默认分组的配置项)
+        rechargeConfigGroupService.createGroupForStation(station.getStationId());
     }
 
     @Override

+ 6 - 2
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -314,7 +314,12 @@ public class WxPayServiceImpl implements WxPayService {
             var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
             if (walletDetail != null) {
 
-                var rechargeConfig = rechargeConfigService.getRechargeConfigByAmount(walletDetail.getAmount());
+                var stationId = transaction.getAttach();
+                var rechargeConfig = rechargeConfigService.getByStationAndAmount(stationId, walletDetail.getAmount());
+                if (rechargeConfig == null) {
+                    LOGGER.error("微信支付回调:未找到充值配置,stationId={}, amount={}", stationId, walletDetail.getAmount());
+                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+                }
 
                 // 更新余额(赠款计入不可退优惠金额)
                 var account = accountService.getAccountByUserId(walletDetail.getUserId());
@@ -356,7 +361,6 @@ public class WxPayServiceImpl implements WxPayService {
                 payLogService.save(payLog);
 
                 // V2 结算方案:充值资金记录分账流水,结算日统一处理,不再即时转入站点账户
-                var stationId = transaction.getAttach();
 
                 // 分账记录(结算时按此汇总)
                 var splitRecord = new SplitRecord()

+ 8 - 2
docs/结算方案-业务说明.md

@@ -126,9 +126,14 @@
       赠款为归属站点自行发起的营销成本,参与跨店分账,由归属站承担
 ```
 
-### 4.2.1 充值配置站点化
+### 4.2.1 充值配置分组化(V2.2)
 
-每个站点可有独立的充值套餐配置(`t_recharge_config` 增加 `station_id` 字段)。`station_id` 为 NULL 的记录为平台默认配置,新站点创建时自动复制默认配置。用户充值页面按归属站展示对应套餐,无专属配置时回退到默认配置。
+充值配置采用「分组 + 配置项」两级模型:
+
+- **`t_recharge_config_group`**:配置分组,`station_id` 关联站点(NULL = 平台默认组),每站一个分组
+- **`t_recharge_config`**:配置项,`group_id` 关联分组,单条记录表示一个充值金额档位
+
+查询逻辑:先查站点专属分组 → 回退默认分组 → 返回组内配置项列表。充值回调(wxNotify)通过「归属站 + 金额」查找配置项,确保同金额不同组时赠款金额正确。新站点创建时自动创建专属分组并复制默认分组的配置项。
 
 ### 4.3 退款
 
@@ -239,5 +244,6 @@
 
 | 版本 | 日期 | 说明 |
 |---|---|---|
+| V2.2 | 2026-05-26 | 充值配置分组化(group + item 两级模型) |
 | V2.1 | 2026-05-26 | 跨店分账基数调整为充值余额+赠款余额;充值配置站点化(`RechargeConfig.stationId`)+ 默认配置回退 |
 | V2-draft | 2026-05-14 | 初稿,基于业务讨论整理 |