瀏覽代碼

feat: 新增专属充值优惠活动和线下充值功能

- 专属优惠活动:运营后台创建充值优惠活动(充值xx送xx),生成小程序二维码,用户扫码享专属优惠。支持所有人/指定用户两种模式,带有效期控制
- 线下充值:运营人员后台代充,直接更新钱包余额,钱不经过微信商户。WalletDetail 新增 source 字段区分 WX_PAY/MANUAL
- 小程序端:新增优惠充值页面,扫码自动跳转
- 新增管理后台页面:专属优惠活动管理、线下充值
- 新增 v7 数据库迁移脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 18 小時之前
父節點
當前提交
ddad10d05d
共有 22 個文件被更改,包括 1598 次插入8 次删除
  1. 30 0
      admin-web/src/router/route.ts
  2. 136 0
      admin-web/src/views/admin/finance/offline-recharge.vue
  3. 162 0
      admin-web/src/views/admin/platform/rechargePromotion/dialog.vue
  4. 161 0
      admin-web/src/views/admin/platform/rechargePromotion/index.vue
  5. 10 0
      car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java
  6. 17 0
      car-wash-admin/src/main/java/com/kym/admin/controller/OfflineRechargeParam.java
  7. 127 0
      car-wash-admin/src/main/java/com/kym/admin/controller/PromotionController.java
  8. 102 0
      car-wash-common/src/main/java/com/kym/common/utils/wx/WxMaQrCodeUtil.java
  9. 80 0
      car-wash-entity/src/main/java/com/kym/entity/RechargePromotion.java
  10. 10 0
      car-wash-entity/src/main/java/com/kym/entity/WalletDetail.java
  11. 41 0
      car-wash-entity/src/main/resources/sql/v7_recharge_promotion.sql
  12. 11 0
      car-wash-mapper/src/main/java/com/kym/mapper/RechargePromotionMapper.java
  13. 17 1
      car-wash-miniapp/src/main/java/com/kym/miniapp/controller/CommonController.java
  14. 11 0
      car-wash-miniapp/src/main/java/com/kym/miniapp/controller/PaymentController.java
  15. 17 1
      car-wash-mp/src/App.vue
  16. 270 0
      car-wash-mp/src/pages-user/wallet/promotion.vue
  17. 7 0
      car-wash-mp/src/pages.json
  18. 23 0
      car-wash-service/src/main/java/com/kym/service/RechargePromotionService.java
  19. 55 0
      car-wash-service/src/main/java/com/kym/service/impl/RechargePromotionServiceImpl.java
  20. 5 0
      car-wash-service/src/main/java/com/kym/service/wechat/WxPayService.java
  21. 112 6
      car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java
  22. 194 0
      doc/专属优惠与线下充值功能说明.md

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

@@ -301,6 +301,21 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                     perm: "recharge.list",
                 },
             },
+            {
+                path: '/offlineRecharge',
+                name: 'adminOfflineRecharge',
+                component: () => import('/@/views/admin/finance/offline-recharge.vue'),
+                meta: {
+                    title: '线下充值',
+                    isLink: '',
+                    isHide: false,
+                    isKeepAlive: true,
+                    isAffix: false,
+                    isIframe: false,
+                    icon: 'ele-Money',
+                    perm: "offlineRecharge",
+                },
+            },
             {
                 path: '/station/account',
                 name: 'adminStationAccount',
@@ -376,6 +391,21 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                             perm: "rechargeConfig.list",
                             icon: 'ele-Money',
                         },
+                    },
+                    {
+                        path: '/platform/rechargePromotion',
+                        name: 'adminPlatformRechargePromotion',
+                        component: () => import('/@/views/admin/platform/rechargePromotion/index.vue'),
+                        meta: {
+                            title: '专属优惠活动',
+                            isLink: '',
+                            isHide: false,
+                            isKeepAlive: true,
+                            isAffix: false,
+                            isIframe: false,
+                            perm: "promotion.list",
+                            icon: 'ele-Gift',
+                        },
                     }
                 ]
             },

+ 136 - 0
admin-web/src/views/admin/finance/offline-recharge.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="system-container layout-padding">
+    <el-card shadow="hover" class="layout-padding-auto">
+      <template #header>
+        <span style="font-weight: 600; font-size: 16px;">线下充值</span>
+      </template>
+
+      <el-form ref="formRef" :model="state.form" :rules="rules" label-width="120px" style="max-width: 600px;">
+        <el-form-item label="用户手机号" prop="mobilePhone">
+          <div style="display: flex; gap: 10px; width: 100%;">
+            <el-input v-model="state.form.mobilePhone" placeholder="请输入用户手机号" style="flex: 1"/>
+            <el-button type="primary" @click="handleSearchUser" :loading="state.searching">
+              <SvgIcon name="ele-Search"/> 查询
+            </el-button>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="用户信息" v-if="state.userInfo">
+          <div style="color: #409eff;">
+            用户ID: {{ state.userInfo.id }} &nbsp;|&nbsp;
+            昵称: {{ state.userInfo.nickname || '-' }} &nbsp;|&nbsp;
+            当前余额: {{ fmtMoney(state.userInfo.balance || 0) }} 元
+          </div>
+        </el-form-item>
+
+        <el-form-item label="充值金额(元)" prop="amountYuan">
+          <el-input-number v-model="state.form.amountYuan" :min="0.01" :precision="2" :step="10"
+                           placeholder="请输入充值金额" style="width: 100%"/>
+        </el-form-item>
+
+        <el-form-item label="赠送金额(元)">
+          <el-input-number v-model="state.form.grantsAmountYuan" :min="0" :precision="2" :step="5"
+                           placeholder="请输入赠送金额(可选)" style="width: 100%"/>
+        </el-form-item>
+
+        <el-form-item label="备注">
+          <el-input v-model="state.form.remark" type="textarea" :rows="3"
+                    placeholder="转账凭证信息、微信流水号等"/>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="success" @click="handleSubmit" :loading="state.submitting" v-auth="'offlineRecharge'">
+            确认充值
+          </el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts" name="AdminOfflineRecharge">
+import { reactive, ref } from 'vue';
+import { $get, $body } from "/@/utils/request";
+import { Msg } from "/@/utils/message";
+
+const formRef = ref();
+
+const fmtMoney = (money: number) => {
+  if (!money) return '0.00';
+  return (money / 100).toFixed(2);
+};
+
+const rules = {
+  mobilePhone: [{ required: true, message: '请输入用户手机号', trigger: 'blur' }],
+  amountYuan: [{ required: true, message: '请输入充值金额', trigger: 'blur' }],
+};
+
+const state = reactive({
+  form: {
+    mobilePhone: '',
+    amountYuan: null as number | null,
+    grantsAmountYuan: 0,
+    remark: '',
+  },
+  userInfo: null as any,
+  searching: false,
+  submitting: false,
+});
+
+const handleSearchUser = () => {
+  if (!state.form.mobilePhone) {
+    Msg.message('请输入手机号', 'warning');
+    return;
+  }
+  state.searching = true;
+  state.userInfo = null;
+  $get('/custom/listUser', { mobilePhone: state.form.mobilePhone, pageNum: 1, pageSize: 1 }).then((res: any) => {
+    const list = res.records || [];
+    if (list.length === 0) {
+      Msg.message('未找到该用户', 'warning');
+    } else {
+      state.userInfo = list[0];
+    }
+    state.searching = false;
+  }).catch(() => {
+    state.searching = false;
+  });
+};
+
+const handleSubmit = () => {
+  formRef.value?.validate((valid: boolean) => {
+    if (!valid) return;
+    if (!state.userInfo) {
+      Msg.message('请先查询用户', 'warning');
+      return;
+    }
+
+    const amount = Math.round((state.form.amountYuan || 0) * 100);
+    const grantsAmount = state.form.grantsAmountYuan ? Math.round(state.form.grantsAmountYuan * 100) : 0;
+    const confirmMsg = `确认为用户 ${state.form.mobilePhone} 充值 ${fmtMoney(amount)} 元${grantsAmount > 0 ? '(赠送 ' + fmtMoney(grantsAmount) + ' 元)' : ''}?`;
+
+    Msg.confirm(confirmMsg).then(() => {
+      state.submitting = true;
+      $body('/finance/offlineRecharge', {
+        userId: state.userInfo.id,
+        amount: amount,
+        grantsAmount: grantsAmount,
+        remark: state.form.remark,
+      }).then(() => {
+        Msg.message('充值成功', 'success');
+        state.submitting = false;
+        handleReset();
+      }).catch(() => {
+        state.submitting = false;
+      });
+    });
+  });
+};
+
+const handleReset = () => {
+  state.form = { mobilePhone: '', amountYuan: null, grantsAmountYuan: 0, remark: '' };
+  state.userInfo = null;
+  formRef.value?.clearValidate();
+};
+</script>

+ 162 - 0
admin-web/src/views/admin/platform/rechargePromotion/dialog.vue

@@ -0,0 +1,162 @@
+<template>
+  <el-dialog
+      v-model="state.visible"
+      :title="state.isEdit ? '编辑活动' : '新增活动'"
+      width="550px"
+      :close-on-click-modal="false"
+      @open="onOpen">
+    <el-form ref="formRef" :model="state.form" :rules="rules" label-width="100px">
+      <el-form-item label="活动标题" prop="title">
+        <el-input v-model="state.form.title" placeholder="请输入活动标题" maxlength="128"/>
+      </el-form-item>
+      <el-form-item label="充值金额(元)" prop="rechargeAmountYuan">
+        <el-input-number v-model="state.form.rechargeAmountYuan" :min="0.01" :precision="2" :step="10"
+                         placeholder="充值金额" style="width: 100%"/>
+      </el-form-item>
+      <el-form-item label="赠送金额(元)" prop="grantsAmountYuan">
+        <el-input-number v-model="state.form.grantsAmountYuan" :min="0" :precision="2" :step="5"
+                         placeholder="赠送金额" style="width: 100%"/>
+      </el-form-item>
+      <el-form-item label="目标模式" prop="targetMode">
+        <el-radio-group v-model="state.form.targetMode">
+          <el-radio :value="1">所有人</el-radio>
+          <el-radio :value="2">指定用户</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="指定用户" prop="targetUserIds" v-if="state.form.targetMode === 2">
+        <el-input v-model="state.form.targetUserIdsStr" placeholder="请输入用户ID,多个用逗号分隔"/>
+      </el-form-item>
+      <el-form-item label="有效期" prop="timeRange">
+        <el-date-picker
+            v-model="state.form.timeRange"
+            type="datetimerange"
+            range-separator="至"
+            start-placeholder="开始时间"
+            end-placeholder="结束时间"
+            format="YYYY-MM-DD HH:mm:ss"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            style="width: 100%"/>
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-switch v-model="state.form.status" :active-value="1" :inactive-value="0"/>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="state.visible = false">取消</el-button>
+      <el-button type="primary" @click="handleSubmit" :loading="state.submitting">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="RechargePromotionDialog">
+import { reactive, ref } from 'vue';
+import { $body } from "/@/utils/request";
+import { Msg } from "/@/utils/message";
+
+const emit = defineEmits(['refresh']);
+const formRef = ref();
+
+const rules = {
+  title: [{ required: true, message: '请输入活动标题', trigger: 'blur' }],
+  rechargeAmountYuan: [{ required: true, message: '请输入充值金额', trigger: 'blur' }],
+  timeRange: [{ required: true, message: '请选择有效期', trigger: 'change' }],
+};
+
+const state = reactive({
+  visible: false,
+  isEdit: false,
+  submitting: false,
+  editId: null as number | null,
+  form: {
+    title: '',
+    rechargeAmountYuan: null as number | null,
+    grantsAmountYuan: 0,
+    targetMode: 1,
+    targetUserIdsStr: '',
+    timeRange: [] as string[],
+    status: 1,
+  },
+});
+
+const open = (mode: string, row: any) => {
+  state.isEdit = mode === 'edit';
+  state.editId = row?.id || null;
+  if (row) {
+    state.form.title = row.title || '';
+    state.form.rechargeAmountYuan = row.rechargeAmount ? (row.rechargeAmount / 100) : null;
+    state.form.grantsAmountYuan = row.grantsAmount ? (row.grantsAmount / 100) : 0;
+    state.form.targetMode = row.targetMode || 1;
+    if (row.targetUserIds) {
+      try {
+        const ids = JSON.parse(row.targetUserIds);
+        state.form.targetUserIdsStr = Array.isArray(ids) ? ids.join(',') : '';
+      } catch {
+        state.form.targetUserIdsStr = '';
+      }
+    } else {
+      state.form.targetUserIdsStr = '';
+    }
+    state.form.timeRange = [row.startTime, row.endTime];
+    state.form.status = row.status ?? 1;
+  } else {
+    resetForm();
+  }
+  state.visible = true;
+};
+
+const resetForm = () => {
+  state.form = {
+    title: '',
+    rechargeAmountYuan: null,
+    grantsAmountYuan: 0,
+    targetMode: 1,
+    targetUserIdsStr: '',
+    timeRange: [],
+    status: 1,
+  };
+};
+
+const onOpen = () => {
+  formRef.value?.clearValidate();
+};
+
+const handleSubmit = () => {
+  formRef.value?.validate((valid: boolean) => {
+    if (!valid) return;
+    state.submitting = true;
+
+    let targetUserIds = null;
+    if (state.form.targetMode === 2 && state.form.targetUserIdsStr) {
+      const ids = state.form.targetUserIdsStr.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n));
+      targetUserIds = JSON.stringify(ids);
+    }
+
+    const data: any = {
+      title: state.form.title,
+      rechargeAmount: Math.round((state.form.rechargeAmountYuan || 0) * 100),
+      grantsAmount: Math.round((state.form.grantsAmountYuan || 0) * 100),
+      targetMode: state.form.targetMode,
+      targetUserIds: targetUserIds,
+      startTime: state.form.timeRange[0],
+      endTime: state.form.timeRange[1],
+      status: state.form.status,
+    };
+
+    if (state.isEdit) {
+      data.id = state.editId;
+    }
+
+    const url = state.isEdit ? '/rechargePromotion/modify' : '/rechargePromotion/add';
+    $body(url, data).then(() => {
+      Msg.message(state.isEdit ? '修改成功' : '新增成功', 'success');
+      state.visible = false;
+      state.submitting = false;
+      emit('refresh');
+    }).catch(() => {
+      state.submitting = false;
+    });
+  });
+};
+
+defineExpose({ open });
+</script>

+ 161 - 0
admin-web/src/views/admin/platform/rechargePromotion/index.vue

@@ -0,0 +1,161 @@
+<style scoped lang="scss">
+.qr-image {
+  max-width: 280px;
+  max-height: 280px;
+  display: block;
+  margin: 0 auto;
+}
+</style>
+<template>
+  <div class="system-container layout-padding">
+    <el-card shadow="hover" class="layout-padding-auto">
+      <div class="page-search mb10" style="display: flex; align-items: center; gap: 10px;">
+        <el-button v-auth="'promotion.add'" size="default" plain type="success" @click="handleAdd">
+          <SvgIcon name="ele-Plus"/>
+          新增活动
+        </el-button>
+      </div>
+
+      <el-table
+          border
+          stripe
+          :data="state.tableData"
+          v-loading="state.loading"
+          row-key="id">
+        <template #empty>
+          <el-empty description="暂无优惠活动"></el-empty>
+        </template>
+        <el-table-column label="活动标题" prop="title" min-width="180" :show-overflow-tooltip="true"/>
+        <el-table-column label="充值金额" prop="rechargeAmount" width="130">
+          <template #default="{ row }">
+            {{ u.fmt.fmtMoney(row.rechargeAmount) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="赠送金额" prop="grantsAmount" width="130">
+          <template #default="{ row }">
+            {{ u.fmt.fmtMoney(row.grantsAmount) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="目标用户" width="120">
+          <template #default="{ row }">
+            <el-tag :type="row.targetMode === 1 ? 'success' : 'warning'" size="small">
+              {{ row.targetMode === 1 ? '所有人' : '指定用户' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="有效期" width="340">
+          <template #default="{ row }">
+            {{ row.startTime }} ~ {{ row.endTime }}
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
+              {{ row.status === 1 ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="260" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button v-auth="'promotion.list'" type="primary" size="small" text @click="handleGenerateQR(row)">二维码</el-button>
+            <el-button v-auth="'promotion.modify'" type="warning" size="small" text @click="handleEdit(row)">编辑</el-button>
+            <el-button v-auth="'promotion.remove'" type="danger" size="small" text @click="handleRemove(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <ext-page class="page-pager mt10" v-model:value="state.pageQuery" @change="loadData"/>
+    </el-card>
+
+    <el-dialog v-model="state.qrDialogVisible" title="活动二维码" width="400px" center>
+      <div style="text-align: center;">
+        <el-image v-if="state.qrCodeUrl" :src="state.qrCodeUrl" class="qr-image" fit="contain"/>
+        <span v-if="state.qrLoading" style="color: #999;">正在生成二维码...</span>
+      </div>
+      <template #footer>
+        <el-button @click="state.qrDialogVisible = false">关闭</el-button>
+        <el-button type="primary" @click="handleGenerateQR(state.currentPromotion, true)">重新生成</el-button>
+      </template>
+    </el-dialog>
+  </div>
+
+  <Dialog ref="dialogRef" @refresh="loadData"/>
+</template>
+
+<script setup lang="ts" name="AdminRechargePromotion">
+import { defineAsyncComponent, reactive, onMounted, ref } from 'vue';
+import { $get, $body } from "/@/utils/request";
+import u from '/@/utils/u'
+import { Msg } from "/@/utils/message";
+
+const Dialog = defineAsyncComponent(() => import("./dialog.vue"));
+
+const dialogRef = ref();
+
+const state = reactive({
+  loading: false,
+  tableData: [] as any[],
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0,
+  },
+  qrDialogVisible: false,
+  qrCodeUrl: '',
+  qrLoading: false,
+  currentPromotion: null as any,
+});
+
+onMounted(() => {
+  loadData();
+});
+
+const loadData = () => {
+  state.loading = true;
+  $get('/rechargePromotion/list', state.pageQuery).then((res: any) => {
+    state.tableData = res.records || [];
+    state.pageQuery.total = res.total || 0;
+    state.loading = false;
+  }).catch(() => {
+    state.loading = false;
+  });
+};
+
+const handleAdd = () => {
+  dialogRef.value.open('add', null);
+};
+
+const handleEdit = (row: any) => {
+  dialogRef.value.open('edit', row);
+};
+
+const handleRemove = (row: any) => {
+  Msg.confirm('此操作将永久删除该活动,是否继续?').then(() => {
+    $get(`/rechargePromotion/remove/${row.id}`).then(() => {
+      Msg.message("删除成功", 'success');
+      loadData();
+    }).catch(() => {
+      Msg.message("删除失败", 'error');
+    });
+  });
+};
+
+const handleGenerateQR = (row: any, force: boolean = false) => {
+  state.currentPromotion = row;
+  state.qrDialogVisible = true;
+  if (row.qrCodeUrl && !force) {
+    state.qrCodeUrl = row.qrCodeUrl;
+    return;
+  }
+  state.qrLoading = true;
+  state.qrCodeUrl = '';
+  $get(`/rechargePromotion/generateQR/${row.id}`).then((url: any) => {
+    state.qrCodeUrl = url;
+    state.qrLoading = false;
+    row.qrCodeUrl = url;
+  }).catch(() => {
+    state.qrLoading = false;
+    Msg.message("生成二维码失败", 'error');
+  });
+};
+</script>

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

@@ -1,5 +1,6 @@
 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.entity.queryParams.*;
@@ -209,4 +210,13 @@ public class FinanceController {
         return R.success(refundLogService.detail(outRefundNo));
     }
 
+    @SaCheckPermission("offlineRecharge")
+    @SysLog("线下充值")
+    @PostMapping("/offlineRecharge")
+    public R<?> offlineRecharge(@RequestBody OfflineRechargeParam param) {
+        wxPayService.offlineRecharge(param.getUserId(), param.getAmount(),
+                param.getGrantsAmount(), param.getRemark());
+        return R.success();
+    }
+
 }

+ 17 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/OfflineRechargeParam.java

@@ -0,0 +1,17 @@
+package com.kym.admin.controller;
+
+import lombok.Data;
+
+/**
+ * 线下充值参数
+ *
+ * @author skyline
+ * @since 2026-06-03
+ */
+@Data
+public class OfflineRechargeParam {
+    private Long userId;
+    private Integer amount;
+    private Integer grantsAmount;
+    private String remark;
+}

+ 127 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/PromotionController.java

@@ -0,0 +1,127 @@
+package com.kym.admin.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.hutool.core.util.RandomUtil;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.kym.common.R;
+import com.kym.common.annotation.SysLog;
+import com.kym.common.utils.OssUtil;
+import com.kym.common.utils.wx.WxMaQrCodeUtil;
+import com.kym.entity.RechargePromotion;
+import com.kym.entity.queryParams.CommonQueryParam;
+import com.kym.service.RechargePromotionService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.UUID;
+
+/**
+ * 专属充值优惠活动管理
+ *
+ * @author skyline
+ * @since 2026-06-03
+ */
+@RestController
+@RequestMapping("/rechargePromotion")
+public class PromotionController {
+
+    private static final Logger log = LoggerFactory.getLogger(PromotionController.class);
+
+    private final RechargePromotionService rechargePromotionService;
+
+    public PromotionController(RechargePromotionService rechargePromotionService) {
+        this.rechargePromotionService = rechargePromotionService;
+    }
+
+    @SaCheckPermission("promotion.list")
+    @GetMapping("/list")
+    R<?> list(@ModelAttribute CommonQueryParam params) {
+        var page = rechargePromotionService.lambdaQuery()
+                .orderByDesc(RechargePromotion::getCreateTime)
+                .page(new Page<>(params.getPageNum(), params.getPageSize()));
+        return R.success(page);
+    }
+
+    @GetMapping("/detail/{id}")
+    R<?> detail(@PathVariable Long id) {
+        return R.success(rechargePromotionService.getById(id));
+    }
+
+    @SaCheckPermission("promotion.add")
+    @SysLog("新增专属优惠活动")
+    @PostMapping("/add")
+    R<?> add(@RequestBody RechargePromotion promotion) {
+        // 自动生成唯一token
+        String token;
+        int retry = 0;
+        do {
+            token = RandomUtil.randomString(16);
+            if (rechargePromotionService.lambdaQuery()
+                    .eq(RechargePromotion::getToken, token).one() == null) {
+                break;
+            }
+            retry++;
+        } while (retry < 5);
+        promotion.setToken(token);
+        rechargePromotionService.save(promotion);
+        return R.success();
+    }
+
+    @SaCheckPermission("promotion.modify")
+    @SysLog("修改专属优惠活动")
+    @PostMapping("/modify")
+    R<?> modify(@RequestBody RechargePromotion promotion) {
+        rechargePromotionService.updateById(promotion);
+        return R.success();
+    }
+
+    @SaCheckPermission("promotion.remove")
+    @SysLog("删除专属优惠活动")
+    @GetMapping("/remove/{id}")
+    R<?> remove(@PathVariable Long id) {
+        rechargePromotionService.removeById(id);
+        return R.success();
+    }
+
+    @SaCheckPermission("promotion.list")
+    @SysLog("生成优惠活动二维码")
+    @GetMapping("/generateQR/{id}")
+    R<?> generateQR(@PathVariable Long id) {
+        var promotion = rechargePromotionService.getById(id);
+        if (promotion == null) {
+            return R.failed(-1, "活动不存在");
+        }
+
+        // 如果已有二维码且未强制刷新,直接返回
+        if (promotion.getQrCodeUrl() != null && !promotion.getQrCodeUrl().isBlank()) {
+            return R.success(promotion.getQrCodeUrl());
+        }
+
+        try {
+            byte[] qrCodeBytes = WxMaQrCodeUtil.generateUnlimitedQrCode(
+                    promotion.getToken(), "pages-user/wallet/promotion");
+
+            // 写入临时文件并上传到OSS
+            String ossKey = "promotion_qr/" + UUID.randomUUID().toString() + ".jpg";
+            File tempFile = File.createTempFile("promotion_qr_", ".jpg");
+            try (FileOutputStream fos = new FileOutputStream(tempFile)) {
+                fos.write(qrCodeBytes);
+            }
+
+            OssUtil.upload(ossKey, tempFile);
+            tempFile.delete();
+
+            String qrCodeUrl = "http://static.kuaiyuman.cn/" + ossKey;
+            promotion.setQrCodeUrl(qrCodeUrl);
+            rechargePromotionService.updateById(promotion);
+
+            return R.success(qrCodeUrl);
+        } catch (Exception e) {
+            log.error("生成二维码失败: id={}", id, e);
+            return R.failed(-1, "生成二维码失败: " + e.getMessage());
+        }
+    }
+}

+ 102 - 0
car-wash-common/src/main/java/com/kym/common/utils/wx/WxMaQrCodeUtil.java

@@ -0,0 +1,102 @@
+package com.kym.common.utils.wx;
+
+import cn.hutool.extra.spring.SpringUtil;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.kym.common.utils.HttpUtil;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.env.Environment;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * 微信小程序二维码工具类
+ *
+ * @author skyline
+ * @since 2026-06-03
+ */
+public class WxMaQrCodeUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(WxMaQrCodeUtil.class);
+    private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder().build();
+    private static String accessToken;
+    private static long expiresAt;
+
+    private WxMaQrCodeUtil() {
+    }
+
+    private static synchronized String getAccessToken() {
+        if (accessToken != null && System.currentTimeMillis() < expiresAt) {
+            return accessToken;
+        }
+        try {
+            Environment env = SpringUtil.getBean(Environment.class);
+            String appId = env.getProperty("wechat.miniapp.appid");
+            String secret = env.getProperty("wechat.miniapp.secret");
+            String url = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
+                    appId, secret);
+            String resp = HttpUtil.get(url);
+            JSONObject json = JSON.parseObject(resp);
+            accessToken = json.getString("access_token");
+            int expiresIn = json.getIntValue("expires_in", 7200);
+            expiresAt = System.currentTimeMillis() + (expiresIn - 300) * 1000L;
+            log.info("获取小程序access_token成功");
+            return accessToken;
+        } catch (Exception e) {
+            log.error("获取小程序access_token失败", e);
+            throw new RuntimeException("获取小程序access_token失败", e);
+        }
+    }
+
+    /**
+     * 生成小程序码(无限数量),返回图片二进制数据
+     *
+     * @param scene 场景值(最大32字符)
+     * @param page  小程序页面路径
+     * @return 图片二进制数据
+     */
+    public static byte[] generateUnlimitedQrCode(String scene, String page) {
+        try {
+            String token = getAccessToken();
+            String url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token;
+
+            Map<String, Object> body = Map.of("scene", scene, "page", page, "check_path", false);
+            String json = JSON.toJSONString(body);
+
+            Request request = new Request.Builder()
+                    .url(url)
+                    .post(RequestBody.create(json, MediaType.get("application/json; charset=utf-8")))
+                    .build();
+
+            try (Response response = HTTP_CLIENT.newCall(request).execute()) {
+                byte[] bytes = response.body().bytes();
+                String contentType = response.header("Content-Type", "");
+                // 如果返回的是JSON,说明出错了
+                if (contentType.contains("application/json")) {
+                    String resp = new String(bytes);
+                    log.error("生成小程序码失败: {}", resp);
+                    throw new RuntimeException("生成小程序码失败: " + resp);
+                }
+                return bytes;
+            }
+        } catch (IOException e) {
+            log.error("生成小程序码异常", e);
+            throw new RuntimeException("生成小程序码异常", e);
+        }
+    }
+
+    /**
+     * 清除缓存的access_token(用于强制刷新)
+     */
+    public static void clearTokenCache() {
+        accessToken = null;
+        expiresAt = 0;
+    }
+}

+ 80 - 0
car-wash-entity/src/main/java/com/kym/entity/RechargePromotion.java

@@ -0,0 +1,80 @@
+package com.kym.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+/**
+ * 专属充值优惠活动
+ *
+ * @author skyline
+ * @since 2026-06-03
+ */
+@Getter
+@Setter
+@TableName("t_recharge_promotion")
+@Accessors(chain = true)
+public class RechargePromotion extends BaseEntity {
+
+    public static final int TARGET_ALL = 1;
+    public static final int TARGET_SPECIFIC = 2;
+
+    public static final int STATUS_DISABLED = 0;
+    public static final int STATUS_ENABLED = 1;
+
+    /**
+     * 活动标题
+     */
+    private String title;
+
+    /**
+     * 充值金额(分)
+     */
+    private Integer rechargeAmount;
+
+    /**
+     * 赠送金额(分)
+     */
+    private Integer grantsAmount;
+
+    /**
+     * 目标用户ID列表(JSON数组),TARGET_ALL时可为null
+     */
+    private String targetUserIds;
+
+    /**
+     * 目标模式: 1=所有人, 2=指定用户
+     */
+    private Integer targetMode;
+
+    /**
+     * 活动开始时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime startTime;
+
+    /**
+     * 活动结束时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime endTime;
+
+    /**
+     * 状态: 0=禁用, 1=启用
+     */
+    private Integer status;
+
+    /**
+     * 二维码场景值(唯一标识)
+     */
+    private String token;
+
+    /**
+     * 二维码图片URL
+     */
+    private String qrCodeUrl;
+}

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

@@ -107,6 +107,16 @@ public class WalletDetail extends BaseEntity implements Serializable {
      */
     private Integer status;
 
+    /**
+     * 来源: WX_PAY-微信支付, MANUAL-线下充值
+     */
+    private String source;
+
+    /**
+     * 关联充值优惠活动ID
+     */
+    private Long promotionId;
+
     /**
      * 备注
      */

+ 41 - 0
car-wash-entity/src/main/resources/sql/v7_recharge_promotion.sql

@@ -0,0 +1,41 @@
+-- ============================================
+-- v7: 专属充值优惠活动 + 线下充值
+-- ============================================
+
+-- 1. WalletDetail 新增字段
+ALTER TABLE `t_wallet_detail`
+    ADD COLUMN `source` VARCHAR(16) DEFAULT 'WX_PAY' COMMENT '来源: WX_PAY-微信支付, MANUAL-线下充值'
+    AFTER `status`;
+
+ALTER TABLE `t_wallet_detail`
+    ADD COLUMN `promotion_id` BIGINT DEFAULT NULL COMMENT '关联优惠活动ID'
+    AFTER `source`;
+
+-- 2. 创建专属充值优惠活动表
+CREATE TABLE IF NOT EXISTS `t_recharge_promotion` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `company_id` BIGINT DEFAULT NULL COMMENT '公司ID',
+    `title` VARCHAR(128) NOT NULL COMMENT '活动标题',
+    `recharge_amount` INT NOT NULL COMMENT '充值金额(分)',
+    `grants_amount` INT NOT NULL DEFAULT 0 COMMENT '赠送金额(分)',
+    `target_user_ids` TEXT DEFAULT NULL COMMENT '目标用户ID列表(JSON数组),NULL=不限制',
+    `target_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '目标模式: 1=所有人, 2=指定用户',
+    `start_time` DATETIME NOT NULL COMMENT '活动开始时间',
+    `end_time` DATETIME NOT NULL COMMENT '活动结束时间',
+    `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 0=禁用, 1=启用',
+    `token` VARCHAR(32) NOT NULL COMMENT '二维码场景值(唯一标识)',
+    `qr_code_url` VARCHAR(512) DEFAULT NULL COMMENT '二维码图片URL',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_token` (`token`),
+    KEY `idx_status_time` (`status`, `start_time`, `end_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='专属充值优惠活动表';
+
+-- 3. 添加权限
+INSERT IGNORE INTO `t_permission` (`name`, `value`, `pid`, `weight`) VALUES
+('优惠活动管理', 'promotion.list', 0, 1),
+('优惠活动新增', 'promotion.add', 0, 2),
+('优惠活动修改', 'promotion.modify', 0, 3),
+('优惠活动删除', 'promotion.remove', 0, 4),
+('线下充值', 'offlineRecharge', 0, 5);

+ 11 - 0
car-wash-mapper/src/main/java/com/kym/mapper/RechargePromotionMapper.java

@@ -0,0 +1,11 @@
+package com.kym.mapper;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.RechargePromotion;
+
+/**
+ * @author skyline
+ * @since 2026-06-03
+ */
+public interface RechargePromotionMapper extends MPJBaseMapper<RechargePromotion> {
+}

+ 17 - 1
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/CommonController.java

@@ -3,6 +3,7 @@ package com.kym.miniapp.controller;
 import com.kym.common.R;
 import com.kym.service.ContactService;
 import com.kym.service.RechargeConfigService;
+import com.kym.service.RechargePromotionService;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -19,10 +20,13 @@ public class CommonController {
 
     private final ContactService contactService;
     private final RechargeConfigService rechargeConfigService;
+    private final RechargePromotionService rechargePromotionService;
 
-    public CommonController(ContactService contactService, RechargeConfigService rechargeConfigService) {
+    public CommonController(ContactService contactService, RechargeConfigService rechargeConfigService,
+                            RechargePromotionService rechargePromotionService) {
         this.contactService = contactService;
         this.rechargeConfigService = rechargeConfigService;
+        this.rechargePromotionService = rechargePromotionService;
     }
 
 
@@ -46,4 +50,16 @@ public class CommonController {
     R<?> rechargeConfig(@RequestParam(required = false) String stationId) {
         return R.success(rechargeConfigService.listByStationId(stationId));
     }
+
+    /**
+     * 查询优惠活动详情(扫码进入时调用)
+     */
+    @GetMapping("/promotionInfo")
+    R<?> promotionInfo(@RequestParam String token) {
+        var promotion = rechargePromotionService.getByToken(token);
+        if (promotion == null) {
+            return R.failed(-1, "优惠活动不存在或已过期");
+        }
+        return R.success(promotion);
+    }
 }

+ 11 - 0
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/PaymentController.java

@@ -48,6 +48,17 @@ public class PaymentController {
     }
 
 
+    /**
+     * 专属优惠活动充值
+     */
+    @ApiLog("用户优惠活动充值微信支付")
+    @GetMapping("/promotionPay")
+    @ResponseBody
+    R<?> promotionPay(@RequestParam String promotionToken,
+                      @RequestParam(value = "stationId", defaultValue = "000") String stationId) {
+        return R.success(wxPayService.promotionPay(promotionToken, stationId));
+    }
+
     @ApiLog("用户申请退款")
     @PostMapping("/wxApplyRefund")
     @ResponseBody

+ 17 - 1
car-wash-mp/src/App.vue

@@ -12,7 +12,7 @@ export default <any>{
     deviceId: null,
     manualLogout: false
   },
-  onLaunch() {
+  onLaunch(options: any) {
     let device =  uni.getWindowInfo();
     let initGlobalData = {
       token: "",
@@ -41,6 +41,22 @@ export default <any>{
       }
     })
 
+    // 处理专属优惠活动扫码进入(wxacode.getUnlimited 的 scene 参数)
+    // #ifdef MP-WEIXIN
+    const scene = options?.query?.scene;
+    if (scene) {
+      // 延迟跳转,确保页面初始化完毕
+      setTimeout(() => {
+        uni.navigateTo({
+          url: `/pages-user/wallet/promotion?promotionToken=${encodeURIComponent(scene)}`,
+          fail: () => {
+            // 如果 navigateTo 失败(可能在 tabBar 页面),尝试用 reLaunch
+          }
+        });
+      }, 800);
+    }
+    // #endif
+
     // this.globalData.token = fetchToken();
   },
   onShow(options: any) {

+ 270 - 0
car-wash-mp/src/pages-user/wallet/promotion.vue

@@ -0,0 +1,270 @@
+<template>
+  <uv-navbar title="专属优惠充值" bgColor="#C6171E" leftIconColor="#FFFFFF" :titleStyle="{ color: '#FFFFFF' }" :autoBack="true" :placeholder="true"></uv-navbar>
+  <view class="container">
+    <!-- 加载中 -->
+    <view v-if="state.loading" class="loading-wrap">
+      <uv-loading-icon text="加载中..."></uv-loading-icon>
+    </view>
+
+    <!-- 活动不存在或已过期 -->
+    <view v-else-if="!state.promotion" class="empty-wrap">
+      <uv-empty text="活动不存在或已过期" icon="error" mode="list"></uv-empty>
+    </view>
+
+    <!-- 活动详情 -->
+    <view v-else class="promotion-card">
+      <view class="promotion-title">{{ state.promotion.title }}</view>
+      <view class="promotion-amounts">
+        <view class="amount-block recharge">
+          <text class="amount-label">充值金额</text>
+          <text class="amount-value">¥{{ fmtAmount(state.promotion.rechargeAmount) }}</text>
+        </view>
+        <view class="amount-divider">
+          <uv-icon name="plus" size="20" color="#999"></uv-icon>
+        </view>
+        <view class="amount-block grants">
+          <text class="amount-label">赠送金额</text>
+          <view class="gift-value">
+            <uv-icon name="gift" size="18"></uv-icon>
+            <text class="amount-value gift">+¥{{ fmtAmount(state.promotion.grantsAmount) }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="promotion-meta">
+        <view class="meta-item">
+          <text class="meta-label">有效期</text>
+          <text class="meta-text">{{ state.promotion.startTime }} ~ {{ state.promotion.endTime }}</text>
+        </view>
+        <view class="meta-item">
+          <text class="meta-label">到账金额</text>
+          <text class="meta-text highlight">¥{{ fmtAmount(state.promotion.rechargeAmount + state.promotion.grantsAmount) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="pay-area" v-if="state.promotion">
+      <uv-button
+        type="primary"
+        shape="circle"
+        iconColor="#FFFFFF"
+        color="#C6171E"
+        text="立即充值"
+        :loading="paying"
+        :disabled="paying"
+        @click="debounceConfirm"
+      ></uv-button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { onLoad } from "@dcloudio/uni-app";
+import { debounce } from "@/utils/common";
+import { get } from "@/utils/https";
+
+const initState = () => ({
+  loading: true,
+  promotion: null as any,
+  promotionToken: "",
+});
+
+const state = reactive(initState());
+const paying = ref(false);
+
+onLoad((options: any) => {
+  if (options.promotionToken) {
+    state.promotionToken = options.promotionToken;
+    loadPromotionInfo();
+  } else {
+    state.loading = false;
+  }
+});
+
+const fmtAmount = (fen: number) => {
+  if (!fen) return "0.00";
+  return (fen / 100).toFixed(2);
+};
+
+const loadPromotionInfo = () => {
+  state.loading = true;
+  get("/common/promotionInfo", { token: state.promotionToken })
+    .then((res: any) => {
+      state.promotion = res;
+      state.loading = false;
+    })
+    .catch(() => {
+      state.loading = false;
+    });
+};
+
+const debounceConfirm = debounce(() => {
+  confirm();
+}, 500);
+
+const confirm = () => {
+  if (!state.promotion) return;
+
+  paying.value = true;
+  uni.showLoading({ title: "支付中..." });
+
+  const homeStationId = getApp<any>().globalData.user?.stationId || "000";
+  get("/payment/promotionPay", {
+    promotionToken: state.promotionToken,
+    stationId: homeStationId,
+  })
+    .then((res: any) => {
+      // #ifdef MP-WEIXIN
+      wx.requestPayment({
+        timeStamp: `${res.timeStamp}`,
+        nonceStr: res.nonceStr,
+        package: res.packageVal,
+        signType: res.signType,
+        paySign: res.paySign,
+        success: () => {
+          uni.showToast({ title: "充值完成", icon: "success" });
+          getApp<any>().globalData.refresh = true;
+          setTimeout(() => {
+            uni.switchTab({ url: "/pages/user/index" });
+          }, 500);
+        },
+        fail: () => {
+          uni.hideLoading();
+          uni.showToast({ title: "支付取消", icon: "none" });
+        },
+      });
+      // #endif
+      // #ifndef MP-WEIXIN
+      uni.hideLoading();
+      uni.showToast({ title: "目前仅支持微信支付", icon: "none" });
+      // #endif
+    })
+    .catch(() => {
+      uni.hideLoading();
+      uni.showToast({ title: "网络异常,请重试", icon: "none" });
+    })
+    .finally(() => {
+      paying.value = false;
+    });
+};
+</script>
+
+<style lang="scss" scoped>
+.container {
+  padding: 40rpx 30rpx;
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  background-color: $uni-bg-color-page;
+  box-sizing: border-box;
+}
+
+.loading-wrap {
+  display: flex;
+  justify-content: center;
+  padding-top: 200rpx;
+}
+
+.empty-wrap {
+  padding-top: 200rpx;
+}
+
+// 活动卡片
+.promotion-card {
+  background-color: $uni-bg-color-card;
+  border-radius: 16rpx;
+  padding: 40rpx 32rpx;
+  margin-bottom: 48rpx;
+}
+
+.promotion-title {
+  font-size: 32rpx;
+  font-weight: $uni-font-weight-bold;
+  color: $uni-text-color;
+  text-align: center;
+  margin-bottom: 36rpx;
+}
+
+.promotion-amounts {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 24rpx;
+  margin-bottom: 36rpx;
+}
+
+.amount-block {
+  text-align: center;
+  padding: 24rpx;
+  border-radius: 12rpx;
+  background-color: $uni-bg-color-page;
+  min-width: 180rpx;
+
+  .amount-label {
+    font-size: 24rpx;
+    color: $uni-text-color-secondary;
+    margin-bottom: 8rpx;
+    display: block;
+  }
+
+  .amount-value {
+    font-size: 40rpx;
+    font-weight: $uni-font-weight-bold;
+    color: $uni-text-color;
+
+    &.gift {
+      color: $uni-color-primary;
+    }
+  }
+}
+
+.amount-divider {
+  padding-top: 20rpx;
+}
+
+.gift-value {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4rpx;
+  color: $uni-color-primary;
+}
+
+.promotion-meta {
+  border-top: 1rpx solid $uni-border-color-light;
+  padding-top: 28rpx;
+
+  .meta-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8rpx 0;
+
+    .meta-label {
+      font-size: 26rpx;
+      color: $uni-text-color-secondary;
+    }
+
+    .meta-text {
+      font-size: 26rpx;
+      color: $uni-text-color;
+
+      &.highlight {
+        font-size: 32rpx;
+        font-weight: $uni-font-weight-bold;
+        color: $uni-color-primary;
+      }
+    }
+  }
+}
+
+.pay-area {
+  width: calc(100vw - 60rpx);
+  position: fixed;
+  bottom: 40rpx;
+  bottom: calc(40rpx + constant(safe-area-inset-bottom));
+  bottom: calc(40rpx + env(safe-area-inset-bottom));
+  left: 30rpx;
+}
+</style>

+ 7 - 0
car-wash-mp/src/pages.json

@@ -93,6 +93,13 @@
             "navigationBarTitleText": "我的退款"
           }
         },
+        {
+          "path": "wallet/promotion",
+          "style": {
+            "navigationStyle": "custom",
+            "navigationBarTitleText": "专属优惠充值"
+          }
+        },
         {
           "path": "login/index",
           "style": {

+ 23 - 0
car-wash-service/src/main/java/com/kym/service/RechargePromotionService.java

@@ -0,0 +1,23 @@
+package com.kym.service;
+
+import com.kym.entity.RechargePromotion;
+import com.kym.service.mybatisplus.MyBaseService;
+
+/**
+ * 专属充值优惠活动服务
+ *
+ * @author skyline
+ * @since 2026-06-03
+ */
+public interface RechargePromotionService extends MyBaseService<RechargePromotion> {
+
+    /**
+     * 根据令牌(场景值)查询有效的优惠活动,校验有效期和状态
+     */
+    RechargePromotion getByToken(String token);
+
+    /**
+     * 校验用户是否有资格参与活动
+     */
+    boolean checkUserEligible(RechargePromotion promotion, Long userId);
+}

+ 55 - 0
car-wash-service/src/main/java/com/kym/service/impl/RechargePromotionServiceImpl.java

@@ -0,0 +1,55 @@
+package com.kym.service.impl;
+
+import cn.hutool.core.util.RandomUtil;
+import com.alibaba.fastjson2.JSON;
+import com.kym.entity.RechargePromotion;
+import com.kym.mapper.RechargePromotionMapper;
+import com.kym.service.RechargePromotionService;
+import com.kym.service.mybatisplus.MyBaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @author skyline
+ * @since 2026-06-03
+ */
+@Slf4j
+@Service
+public class RechargePromotionServiceImpl extends MyBaseServiceImpl<RechargePromotionMapper, RechargePromotion> implements RechargePromotionService {
+
+    @Override
+    public RechargePromotion getByToken(String token) {
+        var promotion = lambdaQuery()
+                .eq(RechargePromotion::getToken, token)
+                .eq(RechargePromotion::getStatus, RechargePromotion.STATUS_ENABLED)
+                .one();
+        if (promotion == null) {
+            return null;
+        }
+        var now = LocalDateTime.now();
+        if (now.isBefore(promotion.getStartTime()) || now.isAfter(promotion.getEndTime())) {
+            return null;
+        }
+        return promotion;
+    }
+
+    @Override
+    public boolean checkUserEligible(RechargePromotion promotion, Long userId) {
+        if (promotion.getTargetMode() == RechargePromotion.TARGET_ALL) {
+            return true;
+        }
+        if (promotion.getTargetUserIds() == null || promotion.getTargetUserIds().isBlank()) {
+            return true;
+        }
+        try {
+            List<Long> userIds = JSON.parseArray(promotion.getTargetUserIds(), Long.class);
+            return userIds != null && userIds.contains(userId);
+        } catch (Exception e) {
+            log.error("解析目标用户ID列表失败: {}", promotion.getTargetUserIds(), e);
+            return false;
+        }
+    }
+}

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

@@ -30,6 +30,11 @@ public interface WxPayService {
 
     ResponseEntity<Object> wxNotify(HttpServletRequest request) throws IOException;
 
+    @Transactional
+    PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId);
+
+    void offlineRecharge(Long userId, Integer amount, Integer grantsAmount, String remark);
+
 
     //================================================================发票=====================================================================
 

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

@@ -94,6 +94,7 @@ public class WxPayServiceImpl implements WxPayService {
     private final StationAccountRecordService stationAccountRecordService;
     private final WashOrderService washOrderService;
     private final MpMsgTemplateService mpMsgTemplateService;
+    private final RechargePromotionService rechargePromotionService;
 
 
     /**
@@ -106,7 +107,8 @@ public class WxPayServiceImpl implements WxPayService {
                             PayLogService payLogService, AccountService accountService,
                             RefundLogService refundLogService,
                             ActivityService activityService, UserRechargeRightsService userRechargeRightsService,
-                            RechargeConfigService rechargeConfigService, StationAccountService stationAccountService, SplitRecordService splitRecordService, StationAccountRecordService stationAccountRecordService, WashOrderService washOrderService, MpMsgTemplateService mpMsgTemplateService) {
+                            RechargeConfigService rechargeConfigService, StationAccountService stationAccountService, SplitRecordService splitRecordService, StationAccountRecordService stationAccountRecordService, WashOrderService washOrderService, MpMsgTemplateService mpMsgTemplateService,
+                            RechargePromotionService rechargePromotionService) {
         this.conf = conf;
         this.walletDetailService = walletDetailService;
         this.payLogService = payLogService;
@@ -120,6 +122,7 @@ public class WxPayServiceImpl implements WxPayService {
         this.stationAccountRecordService = stationAccountRecordService;
         this.washOrderService = washOrderService;
         this.mpMsgTemplateService = mpMsgTemplateService;
+        this.rechargePromotionService = rechargePromotionService;
     }
 
     /**
@@ -261,6 +264,52 @@ public class WxPayServiceImpl implements WxPayService {
         return service.prepayWithRequestPayment(request);
     }
 
+    /**
+     * 专属优惠活动充值支付下单
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId) {
+        var promotion = rechargePromotionService.getByToken(promotionToken);
+        if (promotion == null) {
+            throw new BusinessException("优惠活动不存在或已过期");
+        }
+
+        var userId = StpUtil.getLoginIdAsLong();
+        if (!rechargePromotionService.checkUserEligible(promotion, userId)) {
+            throw new BusinessException("您不在本次活动范围内");
+        }
+
+        var openid = StpUtil.getSession().getString("openid");
+        var outTradeNo = OrderUtils.getOrderNo();
+
+        var walletDetail = new WalletDetail()
+                .setType(WalletDetail.TYPE_充值)
+                .setStatus(WalletDetail.STATUS_待确认)
+                .setUserId(userId)
+                .setAmount(promotion.getRechargeAmount())
+                .setOrderNo(outTradeNo)
+                .setPromotionId(promotion.getId())
+                .setSource("WX_PAY");
+        walletDetailService.save(walletDetail);
+
+        PrepayRequest request = new PrepayRequest();
+        Amount amount = new Amount();
+        amount.setTotal(promotion.getRechargeAmount());
+        request.setAmount(amount);
+        request.setAppid(conf.getAppid());
+        request.setMchid(conf.getMchid());
+        request.setDescription("超级进化车生活充值(专属优惠)");
+        request.setNotifyUrl(conf.getNotifyUrl());
+        request.setOutTradeNo(outTradeNo);
+        request.setAttach(stationId);
+        Payer payer = new Payer();
+        payer.setOpenid(openid);
+        request.setPayer(payer);
+        JsapiServiceExtension service = new JsapiServiceExtension.Builder().config(config).build();
+        return service.prepayWithRequestPayment(request);
+    }
+
     /**
      * 微信支付订单号查询订单
      */
@@ -315,19 +364,28 @@ public class WxPayServiceImpl implements WxPayService {
             if (walletDetail != null) {
 
                 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();
+
+                // 确定赠款金额:优先使用优惠活动配置,否则使用站点充值配置
+                Integer grantsAmount;
+                if (walletDetail.getPromotionId() != null) {
+                    var promotion = rechargePromotionService.getById(walletDetail.getPromotionId());
+                    grantsAmount = promotion != null ? promotion.getGrantsAmount() : 0;
+                } else {
+                    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();
+                    }
+                    grantsAmount = rechargeConfig.getGrantsAmount();
                 }
 
                 // 更新余额(赠款计入不可退优惠金额)
                 var account = accountService.getAccountByUserId(walletDetail.getUserId());
-                var grantsAmount = rechargeConfig.getGrantsAmount();
                 accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
                         .eq(Account::getUserId, walletDetail.getUserId()).update();
 
                 walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
+                walletDetail.setSource("WX_PAY");
                 walletDetail.setCurrency(transaction.getAmount().getCurrency());
                 walletDetail.setAmount(transaction.getAmount().getTotal());
                 walletDetail.setGrantsAmount(grantsAmount);
@@ -393,6 +451,54 @@ public class WxPayServiceImpl implements WxPayService {
         }
     }
 
+    /**
+     * 线下充值(运营人员代充)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void offlineRecharge(Long userId, Integer amount, Integer grantsAmount, String remark) {
+        var account = accountService.getAccountByUserId(userId);
+        if (account == null) {
+            throw new BusinessException("用户账户不存在");
+        }
+
+        var tradeNo = OrderUtils.getOrderNo();
+        int gAmount = grantsAmount != null ? grantsAmount : 0;
+
+        var walletDetail = new WalletDetail()
+                .setType(WalletDetail.TYPE_充值)
+                .setStatus(WalletDetail.STATUS_已确认)
+                .setUserId(userId)
+                .setAmount(amount)
+                .setGrantsAmount(gAmount)
+                .setOrderNo(tradeNo)
+                .setSource("MANUAL")
+                .setRemark(remark)
+                .setBeforeBalance(account.getBalance())
+                .setAfterBalance(account.getBalance() + amount)
+                .setBeforeGrantsBalance(account.getGrantsBalance())
+                .setAfterGrantsBalance(account.getGrantsBalance() + gAmount)
+                .setTransactionTime(LocalDateTime.now());
+        walletDetailService.save(walletDetail);
+
+        accountService.lambdaUpdate()
+                .setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}",
+                        amount, gAmount)
+                .eq(Account::getUserId, userId)
+                .update();
+
+        var stationId = KymCache.INSTANCE.getUserStationId(userId);
+        var splitRecord = new SplitRecord()
+                .setFromStationId(stationId)
+                .setToStationId(stationId)
+                .setTradeNo(tradeNo)
+                .setAmount(amount)
+                .setType(SplitRecord.TYPE_RECHARGE);
+        splitRecordService.save(splitRecord);
+
+        LOGGER.info("线下充值完成:userId={}, amount={}, grantsAmount={}, tradeNo={}", userId, amount, gAmount, tradeNo);
+    }
+
     /**
      * 申请退款
      */

+ 194 - 0
doc/专属优惠与线下充值功能说明.md

@@ -0,0 +1,194 @@
+# 专属优惠充值 + 线下充值 功能说明
+
+> 版本: v7  
+> 日期: 2026-06-03  
+> 作者: skyline
+
+---
+
+## 一、专属充值优惠活动
+
+### 1.1 功能概述
+
+运营后台创建专属充值配置(如充值 100 元送 20 元),生成小程序二维码。用户扫码进入小程序,在有效期内以专属优惠完成充值。二维码支持多人使用,可限定目标用户范围。
+
+### 1.2 业务流程
+
+```
+运营后台                    微信小程序                      后端
+   |                          |                             |
+   |-- 创建优惠活动 --------->|                             |
+   |                          |                             |
+   |-- 生成小程序二维码 ----->|                             |
+   |   (wxacode.getUnlimited)|                             |
+   |                          |                             |
+   |                          |-- 用户扫码 --> 小程序启动 --|
+   |                          |   App.vue 检测 scene       |
+   |                          |   跳转优惠充值页            |
+   |                          |                             |
+   |                          |-- GET /common/promotionInfo ->
+   |                          |   (查询活动详情)            |
+   |                          |                             |
+   |                          |-- GET /payment/promotionPay ->
+   |                          |   (发起微信支付)            |
+   |                          |                             |
+   |                          |-- 微信支付成功回调 --------->|
+   |                          |   wxNotify 检测 promotionId |
+   |                          |   使用活动赠费更新余额       |
+```
+
+### 1.3 数据库
+
+**新表: `t_recharge_promotion`**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| title | VARCHAR(128) | 活动标题 |
+| recharge_amount | INT | 充值金额(分) |
+| grants_amount | INT | 赠送金额(分) |
+| target_mode | TINYINT | 1=所有人, 2=指定用户 |
+| target_user_ids | TEXT | 目标用户ID列表(JSON数组),null=不限制 |
+| start_time | DATETIME | 活动开始时间 |
+| end_time | DATETIME | 活动结束时间 |
+| status | TINYINT | 0=禁用, 1=启用 |
+| token | VARCHAR(32) | 二维码场景值(唯一标识) |
+| qr_code_url | VARCHAR(512) | 二维码图片URL |
+
+`t_wallet_detail` 新增字段:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| source | VARCHAR(16) | WX_PAY / MANUAL |
+| promotion_id | BIGINT | 关联优惠活动ID,null=非活动充值 |
+
+### 1.4 API 接口
+
+#### 管理后台
+
+| 方法 | 路径 | 权限 | 说明 |
+|------|------|------|------|
+| GET | `/rechargePromotion/list` | promotion.list | 分页查询活动列表 |
+| GET | `/rechargePromotion/detail/{id}` | — | 查询活动详情 |
+| POST | `/rechargePromotion/add` | promotion.add | 新增活动(自动生成token) |
+| POST | `/rechargePromotion/modify` | promotion.modify | 修改活动 |
+| GET | `/rechargePromotion/remove/{id}` | promotion.remove | 删除活动 |
+| GET | `/rechargePromotion/generateQR/{id}` | promotion.list | 生成小程序二维码(上传OSS) |
+
+#### 小程序端
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/common/promotionInfo?token=xxx` | 查询优惠活动详情(含有效期校验) |
+| GET | `/payment/promotionPay?promotionToken=xxx&stationId=xxx` | 发起优惠活动充值支付,返回JSAPI参数 |
+
+### 1.5 关键逻辑
+
+**支付回调区分优惠活动与普通充值:**
+
+`WxPayServiceImpl.wxNotify()` 中先检查 `walletDetail.promotionId`:
+- 不为 null → 用优惠活动的 `grantsAmount`
+- 为 null → 走原有站点充值配置逻辑
+
+**用户资格校验:**
+
+`RechargePromotionServiceImpl.checkUserEligible()`:
+- `targetMode=1`(所有人)→ 直接通过
+- `targetMode=2`(指定用户)→ 从 `targetUserIds` JSON 数组解析,检查当前用户ID是否在列表中
+
+---
+
+## 二、线下充值
+
+### 2.1 功能概述
+
+用户通过微信转账给运营人员,运营人员在管理后台代为充值,直接更新用户钱包余额。钱不经过微信商户,无微信交易记录。
+
+### 2.2 业务流程
+
+```
+用户 --(微信转账)--> 运营人员
+                        |
+                        |-- 登录管理后台 → 线下充值页面
+                        |-- 输入用户手机号 → 查询用户
+                        |-- 输入充值金额 + 赠送金额 + 备注
+                        |-- 确认提交
+                        |
+后端: 创建 WalletDetail (source=MANUAL, status=已确认)
+     更新 Account 余额
+     创建 SplitRecord (走V2结算)
+     不创建 PayLog (无微信交易)
+```
+
+### 2.3 API 接口
+
+#### 管理后台
+
+| 方法 | 路径 | 权限 | 说明 |
+|------|------|------|------|
+| POST | `/finance/offlineRecharge` | offlineRecharge | 线下充值 |
+
+**请求参数 (JSON Body):**
+
+```json
+{
+  "userId": 10001,
+  "amount": 5000,
+  "grantsAmount": 500,
+  "remark": "微信转账充值,流水号: xxx"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| userId | Long | 是 | 用户ID |
+| amount | Integer | 是 | 充值金额(分) |
+| grantsAmount | Integer | 否 | 赠送金额(分),默认0 |
+| remark | String | 否 | 备注信息 |
+
+### 2.4 与线上充值的区别
+
+| | 线上充值(微信支付) | 线下充值(人工) |
+|------|------|------|
+| WalletDetail.source | WX_PAY | MANUAL |
+| WalletDetail.status | 先创建待确认→回调后确认 | 直接已确认 |
+| PayLog | 创建 | **不创建** |
+| SplitRecord | 创建 | 创建 |
+| 退款 | 走微信退款流程 | 暂不支持系统退款(需人工处理) |
+| 操作日志 | 微信回调日志 | @SysLog "线下充值" |
+
+---
+
+## 三、部署清单
+
+### 3.1 数据库迁移
+
+执行 SQL: `car-wash-entity/src/main/resources/sql/v7_recharge_promotion.sql`
+
+### 3.2 权限分配
+
+在角色管理中为运营人员分配以下权限:
+
+| 权限标识 | 名称 | 菜单 |
+|------|------|------|
+| promotion.list | 优惠活动管理 | 平台配置 → 专属优惠活动 |
+| promotion.add | 优惠活动新增 | 新增按钮 |
+| promotion.modify | 优惠活动修改 | 编辑按钮 |
+| promotion.remove | 优惠活动删除 | 删除按钮 |
+| offlineRecharge | 线下充值 | 财务管理 → 线下充值 |
+
+### 3.3 小程序发布
+
+1. 确保 `pages-user/wallet/promotion` 页面已注册到 `pages.json`
+2. 重新发布小程序(新增页面需审核)
+3. 确保 `wechat.miniapp.appid` 和 `wechat.miniapp.secret` 配置正确(用于生成小程序码)
+
+---
+
+## 四、注意事项
+
+1. **二维码有效期**:微信小程序码本身无过期时间,有效期由活动的 `start_time` / `end_time` 控制,扫码时后端校验
+2. **线下充值不可退款**:线下充值的资金未经过微信商户,系统无法通过微信退款接口原路退回。如需退线下充值款项,需人工线下处理
+3. **线下充值的安全性**:依赖 `offlineRecharge` 权限控制,所有操作通过 `@SysLog` 记录审计日志,WalletDetail.remark 字段记录操作备注
+4. **活动 token 唯一性**:创建活动时自动生成 16 位随机字符串作为 token,数据库有唯一索引防止冲突,失败时自动重试最多 5 次
+5. **小程序码图片存储**:二维码图片上传到阿里云 OSS,URL 存入 `qr_code_url` 字段,重复请求直接返回已有 URL