Ver Fonte

feat: 新增用户锁定功能,禁止锁定用户开机/充值/退款并留存锁定记录

- 锁定需填写锁定备注(仅平台人员可见)与提示内容(用户开机时强制弹出)
- Service 层硬拦截启动设备、申请退款、处理退款、充值;微信回调不拦截保证资金一致性
- 新增 t_user_lock_record 表留存每次锁定记录,用户列表展示锁定次数并可查看明细
- 修复 /user/updateUser 批量赋值漏洞:用户可改写 openid 后重新注册绕过锁定
- 附带此前未提交的设备页游客查询、服务价格说明文案与钱包页改动

Co-Authored-By: Claude Code <noreply@anthropic.com>
skyline há 1 mês atrás
pai
commit
cde3f0e4c1
29 ficheiros alterados com 1545 adições e 65 exclusões
  1. 28 1
      admin-h5/src/api/user.js
  2. 298 2
      admin-h5/src/pages/user/list.vue
  3. 163 2
      admin-web/src/views/admin/account/index.vue
  4. 35 1
      car-wash-admin/src/main/java/com/kym/admin/controller/CustomController.java
  5. 4 1
      car-wash-common/src/main/java/com/kym/common/constant/ResponseEnum.java
  6. 32 1
      car-wash-entity/src/main/java/com/kym/entity/User.java
  7. 78 0
      car-wash-entity/src/main/java/com/kym/entity/UserLockRecord.java
  8. 29 0
      car-wash-entity/src/main/java/com/kym/entity/queryParams/UserLockParam.java
  9. 21 0
      car-wash-entity/src/main/java/com/kym/entity/vo/CustomUserVo.java
  10. 4 0
      car-wash-entity/src/main/java/com/kym/entity/vo/UserVo.java
  11. 51 0
      car-wash-entity/src/main/resources/sql/v23_add_user_lock.sql
  12. 32 0
      car-wash-entity/src/main/resources/sql/v24_add_user_lock_record.sql
  13. 14 0
      car-wash-mapper/src/main/java/com/kym/mapper/UserLockRecordMapper.java
  14. 3 2
      car-wash-mapper/src/main/java/com/kym/mapper/UserMapper.java
  15. 22 3
      car-wash-mapper/src/main/resources/mappers/UserMapper.xml
  16. 1 0
      car-wash-miniapp/src/main/java/com/kym/miniapp/controller/WashDeviceController.java
  17. 4 2
      car-wash-mp/src/components/price-table/index.vue
  18. 228 4
      car-wash-mp/src/pages-user/wallet/index.vue
  19. 88 4
      car-wash-mp/src/pages-user/wallet/refund.vue
  20. 209 38
      car-wash-mp/src/pages-wash/device/index.vue
  21. 1 1
      car-wash-mp/src/pages-wash/station/index.vue
  22. 6 0
      car-wash-mp/src/utils/https.ts
  23. 24 0
      car-wash-service/src/main/java/com/kym/service/UserLockRecordService.java
  24. 16 0
      car-wash-service/src/main/java/com/kym/service/UserService.java
  25. 28 0
      car-wash-service/src/main/java/com/kym/service/impl/UserLockRecordServiceImpl.java
  26. 82 1
      car-wash-service/src/main/java/com/kym/service/impl/UserServiceImpl.java
  27. 11 2
      car-wash-service/src/main/java/com/kym/service/impl/WashDeviceServiceImpl.java
  28. 9 0
      car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java
  29. 24 0
      car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

+ 28 - 1
admin-h5/src/api/user.js

@@ -1,4 +1,4 @@
-import { get } from '../utils'
+import { get, post } from '../utils'
 
 /**
  * 获取用户列表
@@ -42,4 +42,31 @@ export const getUserDetail = (id) => {
  */
 export const getAppUserList = (params) => {
   return get('/custom/listUser', params)
+}
+
+/**
+ * 锁定用户
+ * @param {Object} data - {userId, lockRemark, lockNotice}
+ * @returns {Promise}
+ */
+export const lockUser = (data) => {
+  return post('/custom/lockUser', data)
+}
+
+/**
+ * 解锁用户
+ * @param {Object} data - {userId}
+ * @returns {Promise}
+ */
+export const unlockUser = (data) => {
+  return post('/custom/unlockUser', data)
+}
+
+/**
+ * 获取用户锁定记录
+ * @param {number} userId - 用户ID
+ * @returns {Promise} 锁定记录列表(按锁定时间倒序)
+ */
+export const getUserLockRecords = (userId) => {
+  return get('/custom/listUserLockRecord', { userId })
 }

+ 298 - 2
admin-h5/src/pages/user/list.vue

@@ -25,8 +25,11 @@
             </view>
           </view>
           <view class="item-header-right">
-            <text class="status-tag" :style="getStatusStyle(item.status)">{{ fmtDictName('User.status', item.status) }}</text>
+            <text v-if="item.lockNotice" class="status-tag locked-tag">已锁定</text>
+            <text v-else class="status-tag" :style="getStatusStyle(item.status)">{{ fmtDictName('User.status', item.status) }}</text>
             <button class="refund-btn" @click.stop="handleApplyRefund(item)">退款</button>
+            <button v-if="!item.lockNotice" class="lock-btn" @click.stop="openLockModal(item)">锁定</button>
+            <button v-else class="unlock-btn" @click.stop="handleUnlock(item)">解锁</button>
           </view>
         </view>
         <view class="item-content">
@@ -61,6 +64,24 @@
               <text class="info-value">{{ formatTime(item.registerTime) }}</text>
             </view>
           </view>
+          <!-- 锁定信息(仅平台人员可见) -->
+          <view class="lock-info">
+            <view class="info-item">
+              <text class="info-label">锁定次数</text>
+              <text v-if="item.lockTimes > 0" class="lock-count-link" @click.stop="openLockRecordModal(item)">
+                {{ item.lockTimes }} 次 ›
+              </text>
+              <text v-else class="info-value">0 次</text>
+            </view>
+            <view class="info-item" v-if="item.lockNotice">
+              <text class="info-label">锁定备注</text>
+              <text class="info-value">{{ item.lockRemark || '-' }}</text>
+            </view>
+            <view class="info-item" v-if="item.lockNotice">
+              <text class="info-label">锁定时间</text>
+              <text class="info-value">{{ formatTime(item.lockTime) }}</text>
+            </view>
+          </view>
           <view class="stats-row">
             <view class="stat-item">
               <text class="stat-num">{{ item.rechargeTimes || 0 }}</text>
@@ -122,13 +143,86 @@
         </view>
       </view>
     </view>
+
+    <!-- 锁定用户弹窗 -->
+    <view class="modal-overlay" v-if="showLockModal" @click="closeLockModal">
+      <view class="modal-card" @click.stop>
+        <text class="modal-title">锁定用户</text>
+        <view class="modal-content">
+          <view class="modal-row">
+            <text class="modal-label">用户</text>
+            <text class="modal-value">{{ lockUserInfo?.mobilePhone || '-' }}</text>
+          </view>
+          <view class="modal-row">
+            <text class="modal-label">锁定备注(仅平台人员可见)</text>
+            <input class="modal-input" type="text" v-model="lockRemark" placeholder="请输入锁定原因" />
+          </view>
+          <view class="modal-row">
+            <text class="modal-label">提示内容(用户启动设备时弹出)</text>
+            <input class="modal-input" type="text" v-model="lockNotice" placeholder="请输入要展示给用户的提示" />
+          </view>
+        </view>
+        <view class="modal-footer">
+          <button class="modal-btn cancel" @click="closeLockModal">取消</button>
+          <button class="modal-btn confirm" @click="confirmLock" :disabled="!lockNotice.trim()">确定锁定</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 锁定记录弹窗 -->
+    <view class="modal-overlay" v-if="showLockRecordModal" @click="closeLockRecordModal">
+      <view class="modal-card lock-record-card" @click.stop>
+        <text class="modal-title">锁定记录</text>
+        <view class="modal-content">
+          <view class="modal-row">
+            <text class="modal-label">用户</text>
+            <text class="modal-value">{{ lockRecordUser?.mobilePhone || '-' }}</text>
+          </view>
+          <scroll-view scroll-y class="lock-record-list" v-if="lockRecordList.length > 0">
+            <view class="lock-record-item" v-for="(rec, idx) in lockRecordList" :key="rec.id || idx">
+              <view class="lock-record-head">
+                <text class="lock-record-index">第 {{ lockRecordList.length - idx }} 次锁定</text>
+                <text class="lock-record-status" :class="rec.status === 1 ? 'locked' : 'unlocked'">
+                  {{ rec.status === 1 ? '锁定中' : '已解锁' }}
+                </text>
+              </view>
+              <view class="lock-record-row">
+                <text class="lock-record-label">锁定时间</text>
+                <text class="lock-record-value">{{ formatTime(rec.lockTime) }}</text>
+              </view>
+              <view class="lock-record-row">
+                <text class="lock-record-label">锁定人</text>
+                <text class="lock-record-value">{{ rec.lockOperator || '-' }}</text>
+              </view>
+              <view class="lock-record-row">
+                <text class="lock-record-label">锁定备注</text>
+                <text class="lock-record-value">{{ rec.lockRemark || '-' }}</text>
+              </view>
+              <view class="lock-record-row">
+                <text class="lock-record-label">提示内容</text>
+                <text class="lock-record-value">{{ rec.lockNotice || '-' }}</text>
+              </view>
+              <view class="lock-record-row" v-if="rec.unlockTime">
+                <text class="lock-record-label">解锁信息</text>
+                <text class="lock-record-value">{{ formatTime(rec.unlockTime) }} / {{ rec.unlockOperator || '-' }}</text>
+              </view>
+            </view>
+          </scroll-view>
+          <view class="lock-record-empty" v-else-if="!lockRecordLoading">暂无锁定记录</view>
+          <view class="lock-record-empty" v-else>加载中...</view>
+        </view>
+        <view class="modal-footer">
+          <button class="modal-btn cancel" @click="closeLockRecordModal">关 闭</button>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script setup>
 import { ref, onMounted } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
-import { getAppUserList } from '../../api/user.js'
+import { getAppUserList, lockUser, unlockUser, getUserLockRecords } from '../../api/user.js'
 import { applyRefund } from '../../api/finance.js'
 import { formatTime, showToast, formatAmount, fmtDictName, getDictColor, copyText } from '../../utils/index.js'
 import { loadDicts } from '../../utils/dict.js'
@@ -143,6 +237,14 @@ const searchKeyword = ref('')
 const showRefundModal = ref(false)
 const refundUser = ref(null)
 const refundReason = ref('')
+const showLockModal = ref(false)
+const lockUserInfo = ref(null)
+const lockRemark = ref('')
+const lockNotice = ref('')
+const showLockRecordModal = ref(false)
+const lockRecordUser = ref(null)
+const lockRecordList = ref([])
+const lockRecordLoading = ref(false)
 
 const getStatusStyle = (status) => {
   const color = getDictColor('User.status', status)
@@ -252,6 +354,81 @@ const confirmRefund = async () => {
   }
 }
 
+const openLockModal = (item) => {
+  lockUserInfo.value = item
+  lockRemark.value = ''
+  lockNotice.value = ''
+  showLockModal.value = true
+}
+
+const closeLockModal = () => {
+  showLockModal.value = false
+  lockUserInfo.value = null
+  lockRemark.value = ''
+  lockNotice.value = ''
+}
+
+const confirmLock = async () => {
+  if (!lockNotice.value.trim()) return
+
+  try {
+    const res = await lockUser({
+      userId: lockUserInfo.value.userId,
+      lockRemark: lockRemark.value.trim(),
+      lockNotice: lockNotice.value.trim()
+    })
+    if (res && res.code === 200) {
+      showToast('锁定成功')
+      closeLockModal()
+      loadData()
+    }
+  } catch (error) {
+    console.error('锁定用户失败:', error)
+  }
+}
+
+const openLockRecordModal = async (item) => {
+  lockRecordUser.value = item
+  lockRecordList.value = []
+  showLockRecordModal.value = true
+  lockRecordLoading.value = true
+  try {
+    const res = await getUserLockRecords(item.userId)
+    if (res && res.code === 200) {
+      lockRecordList.value = res.data || []
+    }
+  } catch (error) {
+    console.error('加载锁定记录失败:', error)
+  } finally {
+    lockRecordLoading.value = false
+  }
+}
+
+const closeLockRecordModal = () => {
+  showLockRecordModal.value = false
+  lockRecordUser.value = null
+  lockRecordList.value = []
+}
+
+const handleUnlock = (item) => {
+  uni.showModal({
+    title: '解锁确认',
+    content: `确认解锁用户『${item.mobilePhone || '-'}』?解锁后该用户可正常启动设备与退款`,
+    success: async (modalRes) => {
+      if (!modalRes.confirm) return
+      try {
+        const res = await unlockUser({ userId: item.userId })
+        if (res && res.code === 200) {
+          showToast('解锁成功')
+          loadData()
+        }
+      } catch (error) {
+        console.error('解锁用户失败:', error)
+      }
+    }
+  })
+}
+
 onMounted(async () => {
   await loadDicts()
   loadData()
@@ -461,6 +638,125 @@ onMounted(async () => {
   line-height: 1.4;
 }
 
+.locked-tag {
+  color: #FFFFFF;
+  background-color: #C6171E;
+}
+
+.lock-btn {
+  padding: 6rpx 20rpx;
+  background: #FFFFFF;
+  color: #C6171E;
+  border: 2rpx solid #C6171E;
+  border-radius: 16rpx;
+  font-size: 22rpx;
+  line-height: 1.4;
+}
+
+.lock-btn:active {
+  background: #FFF0F0;
+}
+
+.unlock-btn {
+  padding: 6rpx 20rpx;
+  background: #FFFFFF;
+  color: #31A24C;
+  border: 2rpx solid #31A24C;
+  border-radius: 16rpx;
+  font-size: 22rpx;
+  line-height: 1.4;
+}
+
+.unlock-btn:active {
+  background: #F0FFF4;
+}
+
+.lock-info {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12rpx;
+  margin-top: 12rpx;
+  padding-top: 12rpx;
+  border-top: 1rpx dashed #F0F0F0;
+}
+
+.lock-count-link {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #C6171E;
+}
+
+/* 锁定记录弹窗 */
+.lock-record-card {
+  width: 660rpx;
+}
+
+.lock-record-list {
+  max-height: 640rpx;
+}
+
+.lock-record-item {
+  border: 1rpx solid #F0F0F0;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 16rpx;
+}
+
+.lock-record-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+
+.lock-record-index {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #1A1A1A;
+}
+
+.lock-record-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+
+.lock-record-status.locked {
+  color: #FFFFFF;
+  background-color: #C6171E;
+}
+
+.lock-record-status.unlocked {
+  color: #666666;
+  background-color: #F0F0F0;
+}
+
+.lock-record-row {
+  display: flex;
+  margin-bottom: 8rpx;
+}
+
+.lock-record-label {
+  width: 130rpx;
+  flex-shrink: 0;
+  font-size: 24rpx;
+  color: #999999;
+}
+
+.lock-record-value {
+  flex: 1;
+  font-size: 24rpx;
+  color: #1A1A1A;
+  word-break: break-all;
+}
+
+.lock-record-empty {
+  font-size: 26rpx;
+  color: #999999;
+  text-align: center;
+  padding: 40rpx 0;
+}
+
 .load-more {
   display: flex;
   justify-content: center;

+ 163 - 2
admin-web/src/views/admin/account/index.vue

@@ -23,6 +23,11 @@
   background-color: var(--el-color-white);
   height: 24px;
 }
+
+.unlock-operator {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
 </style>
 <template>
   <div class="system-container layout-padding">
@@ -105,7 +110,8 @@
              <ext-d-label type="User.followOfficialAccount" v-model="row[field.prop]"/>
             </template>
             <template v-else-if="'status'===field.prop">
-             <ext-d-label type="User.status" v-model="row[field.prop]"/>
+             <el-tag v-if="row.lockNotice" type="danger" effect="dark" size="small">已锁定</el-tag>
+             <ext-d-label v-else type="User.status" v-model="row[field.prop]"/>
             </template>
             <template v-else-if="'rechargeTimes'===field.prop">
               <div class="flex w100 flex-justify-around hp">
@@ -122,8 +128,18 @@
                 <span>  <el-button link type="primary" @click="handleGotoRefund(row)">{{row[field.prop]}} <SvgIcon name="ele-Link" class="hc"></SvgIcon></el-button></span>
               </div>
             </template>
+            <template v-else-if="'lockTimes'===field.prop">
+              <div class="flex w100 flex-justify-around hp">
+                <span v-if="row[field.prop] > 0">
+                  <el-button link type="danger" @click="openLockRecordDialog(row)">{{row[field.prop]}} <SvgIcon name="ele-Link" class="hc"></SvgIcon></el-button>
+                </span>
+                <span v-else>0</span>
+              </div>
+            </template>
             <template v-else-if="'action'===field.prop">
              <el-text class="cursor-pointer" type="primary" @click="handleUserRefundApply(row)">申请退款</el-text>
+             <el-text v-if="!row.lockNotice" v-auth="'user.lock'" class="cursor-pointer ml10" type="danger" @click="openLockDialog(row)">锁定</el-text>
+             <el-text v-else v-auth="'user.lock'" class="cursor-pointer ml10" type="success" @click="handleUnlockUser(row)">解锁</el-text>
             </template>
             <template v-else>
               <div>{{ row[field.prop] }}</div>
@@ -136,6 +152,82 @@
       <ext-page class="page-pager" v-model:value="state.pageQuery" @change="loadData(false)"/>
     </el-card>
   </div>
+
+  <!-- 锁定用户弹窗 -->
+  <el-dialog
+      v-model="state.lockDialog.visible"
+      :title="`锁定用户『${state.lockDialog.mobilePhone || ''}』`"
+      width="520px"
+      append-to-body
+      :close-on-click-modal="false">
+    <el-form label-width="90px" size="default">
+      <el-form-item label="锁定备注">
+        <el-input
+            v-model="state.lockDialog.lockRemark"
+            type="textarea"
+            :rows="2"
+            maxlength="200"
+            show-word-limit
+            placeholder="仅平台人员可见,用于记录锁定原因"/>
+      </el-form-item>
+      <el-form-item label="提示内容" required>
+        <el-input
+            v-model="state.lockDialog.lockNotice"
+            type="textarea"
+            :rows="3"
+            maxlength="200"
+            show-word-limit
+            placeholder="用户启动设备时强制弹出展示"/>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="state.lockDialog.visible = false" size="default">取 消</el-button>
+        <el-button
+            :loading="state.lockDialog.loading"
+            :disabled="!state.lockDialog.lockNotice || !state.lockDialog.lockNotice.trim()"
+            type="danger"
+            @click="confirmLockUser"
+            size="default">确定锁定</el-button>
+      </div>
+    </template>
+  </el-dialog>
+
+  <!-- 锁定记录弹窗 -->
+  <el-dialog
+      v-model="state.lockRecordDialog.visible"
+      :title="`锁定记录『${state.lockRecordDialog.mobilePhone || ''}』`"
+      width="900px"
+      append-to-body>
+    <el-table border stripe :data="state.lockRecordDialog.list" v-loading="state.lockRecordDialog.loading" max-height="420">
+      <template #empty>
+        <el-empty description="暂无锁定记录"></el-empty>
+      </template>
+      <el-table-column label="序号" type="index" width="60" align="center"/>
+      <el-table-column label="锁定时间" prop="lockTime" width="160" align="center"/>
+      <el-table-column label="锁定人" prop="lockOperator" width="110" show-overflow-tooltip/>
+      <el-table-column label="锁定备注(仅平台人员可见)" prop="lockRemark" min-width="160" show-overflow-tooltip/>
+      <el-table-column label="提示内容" prop="lockNotice" min-width="160" show-overflow-tooltip/>
+      <el-table-column label="状态" width="90" align="center">
+        <template #default="{row}">
+          <el-tag v-if="row.status === 1" type="danger" effect="dark" size="small">锁定中</el-tag>
+          <el-tag v-else type="info" size="small">已解锁</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="解锁信息" width="200">
+        <template #default="{row}">
+          <div v-if="row.unlockTime">{{ row.unlockTime }}</div>
+          <div v-if="row.unlockOperator" class="unlock-operator">解锁人:{{ row.unlockOperator }}</div>
+        </template>
+      </el-table-column>
+    </el-table>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="state.lockRecordDialog.visible = false" size="default">关 闭</el-button>
+      </div>
+    </template>
+  </el-dialog>
+
   <AdminUserDialog ref="adminUserDialogRef" @refresh="loadData(true)"/>
 </template>
 
@@ -180,6 +272,9 @@ const state = reactive({
       {label: '赠金余额', width: 90, prop: 'grantsBalance', resizable: true, fixed: 'left'},
       {label: '冻结余额', width: 90, prop: 'frozenAmount', resizable: true, fixed: 'left'},
       {label: '状态', width: 80, prop: 'status', align: 'center'},
+      {label: '锁定备注', width: 150, prop: 'lockRemark', resizable: true},
+      {label: '锁定次数', width: 90, prop: 'lockTimes', align: 'center'},
+      {label: '锁定时间', width: 160, prop: 'lockTime', resizable: true},
       {label: '关注公众号', width: 110, prop: 'followOfficialAccount', align: 'center'},
       {label: '注册时间', width: 160, prop: 'registerTime', resizable: true},
       {label: '充值次数', width: 90, prop: 'rechargeTimes', resizable: true},
@@ -193,10 +288,24 @@ const state = reactive({
       {label: '优惠总额', width: 100, prop: 'discountAmount', resizable: true},
       {label: '退款扣除优惠', width: 125, prop: 'refundDiscountAmount', resizable: true},
       {
-        label: '操作', prop: 'action', width: 180, align: 'center', fixed: 'right',
+        label: '操作', prop: 'action', width: 220, align: 'center', fixed: 'right',
       }
     ],
   },
+  lockDialog: {
+    visible: false,
+    loading: false,
+    userId: null,
+    mobilePhone: '',
+    lockRemark: '',
+    lockNotice: ''
+  },
+  lockRecordDialog: {
+    visible: false,
+    loading: false,
+    mobilePhone: '',
+    list: [] as Array<any>
+  }
 })
 
 
@@ -325,6 +434,58 @@ const handleUserRefundApply = (row:any) => {
 
 }
 
+// 打开锁定用户弹窗
+const openLockDialog = (row: any) => {
+  state.lockDialog.userId = row.userId;
+  state.lockDialog.mobilePhone = row.mobilePhone;
+  state.lockDialog.lockRemark = '';
+  state.lockDialog.lockNotice = '';
+  state.lockDialog.visible = true;
+}
+
+// 提交锁定
+const confirmLockUser = () => {
+  state.lockDialog.loading = true;
+  $body(`custom/lockUser`, {
+    userId: state.lockDialog.userId,
+    lockRemark: state.lockDialog.lockRemark,
+    lockNotice: state.lockDialog.lockNotice.trim()
+  }).then(() => {
+    Msg.message("锁定成功")
+    state.lockDialog.visible = false;
+    loadData(true)
+  }).catch(e => {
+    // 错误提示由请求拦截器统一处理
+  }).finally(() => {
+    state.lockDialog.loading = false;
+  })
+}
+
+// 打开锁定记录弹窗
+const openLockRecordDialog = (row: any) => {
+  state.lockRecordDialog.mobilePhone = row.mobilePhone;
+  state.lockRecordDialog.list = [];
+  state.lockRecordDialog.visible = true;
+  state.lockRecordDialog.loading = true;
+  $get(`custom/listUserLockRecord`, {userId: row.userId}).then((res: any) => {
+    state.lockRecordDialog.list = res || [];
+  }).catch(() => {
+    // 错误提示由请求拦截器统一处理
+  }).finally(() => {
+    state.lockRecordDialog.loading = false;
+  })
+}
+
+// 解锁用户
+const handleUnlockUser = (row: any) => {
+  Msg.confirm(`确认解锁用户『${row.mobilePhone}』?解锁后该用户可正常启动设备与退款`).then(() => {
+    $body(`custom/unlockUser`, {userId: row.userId}).then(() => {
+      Msg.message("解锁成功")
+      loadData(true)
+    })
+  })
+}
+
 //endregion
 
 

+ 35 - 1
car-wash-admin/src/main/java/com/kym/admin/controller/CustomController.java

@@ -4,14 +4,22 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
 import com.kym.common.R;
 import com.kym.common.annotation.SysLog;
 import com.kym.entity.queryParams.CommonQueryParam;
+import com.kym.entity.queryParams.UserLockParam;
 import com.kym.service.PayLogService;
+import com.kym.service.UserLockRecordService;
 import com.kym.service.UserService;
+import jakarta.validation.Valid;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.ModelAttribute;
 import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.Map;
+
 /**
  * 用户相关信息
  *
@@ -24,11 +32,14 @@ public class CustomController {
 
     private final UserService customUserService;
     private final PayLogService payLogService;
+    private final UserLockRecordService userLockRecordService;
 
 
-    public CustomController(UserService customUserService, PayLogService payLogService) {
+    public CustomController(UserService customUserService, PayLogService payLogService,
+                            UserLockRecordService userLockRecordService) {
         this.customUserService = customUserService;
         this.payLogService = payLogService;
+        this.userLockRecordService = userLockRecordService;
     }
 
     @SaCheckPermission("account.list")
@@ -38,6 +49,29 @@ public class CustomController {
         return R.success(customUserService.listCustomUser(params));
     }
 
+    @SaCheckPermission("user.lock")
+    @SysLog("锁定用户")
+    @PostMapping("/lockUser")
+    public R<?> lockUser(@RequestBody @Valid UserLockParam params) {
+        customUserService.lockUser(params.getUserId(), params.getLockRemark(), params.getLockNotice());
+        return R.success();
+    }
+
+    @SaCheckPermission("user.lock")
+    @SysLog("解锁用户")
+    @PostMapping("/unlockUser")
+    public R<?> unlockUser(@RequestBody Map<String, Long> params) {
+        customUserService.unlockUser(params.get("userId"));
+        return R.success();
+    }
+
+    @SaCheckPermission("account.list")
+    @SysLog("查询用户锁定记录")
+    @GetMapping("/listUserLockRecord")
+    public R<?> listUserLockRecord(@RequestParam Long userId) {
+        return R.success(userLockRecordService.listByUserId(userId));
+    }
+
     @SysLog("充值记录列表")
     @GetMapping("/listRecharge")
     public R<?> listRecharge(@ModelAttribute CommonQueryParam params) {

+ 4 - 1
car-wash-common/src/main/java/com/kym/common/constant/ResponseEnum.java

@@ -32,7 +32,10 @@ public enum ResponseEnum implements BusinessExceptionAssert {
 
     // 登录 权限
     LOGIN_FAILED(10001, "用户名或密码错误"),
-    NO_PERMISSION(10002, "无访问权限");
+    NO_PERMISSION(10002, "无访问权限"),
+
+    // 用户锁定
+    USER_LOCKED(10003, "该用户已被锁定");
 
     private final Integer code;
     private final String message;

+ 32 - 1
car-wash-entity/src/main/java/com/kym/entity/User.java

@@ -25,6 +25,16 @@ public class User extends BaseEntity implements Serializable {
 
     public static final String ST_SESSION_KEY_MOBILE = "mobilePhone";
 
+    /**
+     * 禁用(被锁定)
+     */
+    public static final int STATUS_禁用 = 0;
+
+    /**
+     * 启用
+     */
+    public static final int STATUS_启用 = 1;
+
     private static final long serialVersionUID = 1L;
 
     /**
@@ -74,10 +84,31 @@ public class User extends BaseEntity implements Serializable {
     private String avatar;
 
     /**
-     * 0:禁用 1:启用
+     * 0:禁用(锁定) 1:启用
      */
     private Integer status;
 
+    /**
+     * 锁定备注(仅平台人员可见,记录锁定原因)
+     */
+    private String lockRemark;
+
+    /**
+     * 锁定提示内容(用户启动设备时强制弹出展示)
+     */
+    private String lockNotice;
+
+    /**
+     * 锁定操作人
+     */
+    private String lockOperator;
+
+    /**
+     * 锁定时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime lockTime;
+
     /**
      * 已关注公众号:0-未关注 1-已关注 2-已取消关注
      */

+ 78 - 0
car-wash-entity/src/main/java/com/kym/entity/UserLockRecord.java

@@ -0,0 +1,78 @@
+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.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * <p>
+ * 用户锁定记录表(每次锁定一条,解锁时回填解锁信息)
+ * </p>
+ *
+ * @author skyline
+ */
+@Getter
+@Setter
+@Accessors(chain = true)
+@TableName("t_user_lock_record")
+public class UserLockRecord extends BaseEntity<UserLockRecord> implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 锁定中
+     */
+    public static final int STATUS_锁定中 = 1;
+
+    /**
+     * 已解锁
+     */
+    public static final int STATUS_已解锁 = 0;
+
+    /**
+     * 用户ID
+     */
+    private Long userId;
+
+    /**
+     * 锁定备注(仅平台人员可见,记录锁定原因)
+     */
+    private String lockRemark;
+
+    /**
+     * 锁定提示内容(用户启动设备时强制弹出展示)
+     */
+    private String lockNotice;
+
+    /**
+     * 锁定操作人
+     */
+    private String lockOperator;
+
+    /**
+     * 锁定时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime lockTime;
+
+    /**
+     * 解锁操作人
+     */
+    private String unlockOperator;
+
+    /**
+     * 解锁时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime unlockTime;
+
+    /**
+     * 状态:1-锁定中 0-已解锁
+     */
+    private Integer status;
+}

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

@@ -0,0 +1,29 @@
+package com.kym.entity.queryParams;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 用户锁定参数
+ */
+@Data
+@Accessors(chain = true)
+public class UserLockParam {
+
+    /**
+     * 用户id
+     */
+    @NotNull(message = "用户id不能为空")
+    private Long userId;
+
+    /**
+     * 锁定备注(仅平台人员可见,记录锁定原因)
+     */
+    private String lockRemark;
+
+    /**
+     * 锁定提示内容(用户启动设备时强制弹出展示)
+     */
+    private String lockNotice;
+}

+ 21 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/CustomUserVo.java

@@ -58,5 +58,26 @@ public class CustomUserVo {
      */
     private int refundDiscountAmount;
     private Integer followOfficialAccount;
+    /**
+     * 锁定备注(仅平台人员可见,记录锁定原因)
+     */
+    private String lockRemark;
+    /**
+     * 锁定提示内容(用户启动设备时强制弹出展示)
+     */
+    private String lockNotice;
+    /**
+     * 锁定操作人
+     */
+    private String lockOperator;
+    /**
+     * 锁定时间
+     */
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime lockTime;
+    /**
+     * 历史累计锁定次数
+     */
+    private int lockTimes;
 
 }

+ 4 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/UserVo.java

@@ -26,6 +26,10 @@ public class UserVo implements Serializable {
     public String gender;
     public String mobilePhone;
     public Integer status;
+    /**
+     * 锁定提示内容(用户端启动设备时强制弹出展示,不含平台内部备注)
+     */
+    public String lockNotice;
     @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     public LocalDateTime lastLoginTime;
     public Integer balance;

+ 51 - 0
car-wash-entity/src/main/resources/sql/v23_add_user_lock.sql

@@ -0,0 +1,51 @@
+-- ====================================================
+-- v23: 用户锁定功能
+-- 背景:洗车站毛巾被盗,平台人员通过监控锁定用户后需要在
+--       平台上标记该用户,禁止其启动设备与退款,并在其启动
+--       设备时强制弹出提示内容。
+-- 1. t_user 新增锁定字段(lock_remark/lock_notice/lock_operator/lock_time)
+--    锁定 = status=0 且 lock_notice 非空;解锁时四个字段一并清空
+-- 2. 新增 user.lock 权限(锁定/解锁用户),超管角色 id=1 幂等补齐
+-- ====================================================
+
+ALTER TABLE `t_user`
+    ADD COLUMN `lock_remark`   VARCHAR(500) DEFAULT NULL COMMENT '锁定备注(仅平台人员可见,记录锁定原因)',
+    ADD COLUMN `lock_notice`   VARCHAR(500) DEFAULT NULL COMMENT '锁定提示内容(用户启动设备时强制弹出展示)',
+    ADD COLUMN `lock_operator` VARCHAR(64)  DEFAULT NULL COMMENT '锁定操作人',
+    ADD COLUMN `lock_time`     DATETIME     DEFAULT NULL COMMENT '锁定时间';
+
+DROP PROCEDURE IF EXISTS add_user_lock_permission;
+DELIMITER //
+CREATE PROCEDURE add_user_lock_permission()
+BEGIN
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_permission'
+    ) THEN
+        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 't_permission 表不存在,请先执行 init.sql';
+    END IF;
+
+    -- 1. 新增权限记录(幂等)
+    IF NOT EXISTS (
+        SELECT 1 FROM t_permission WHERE value = 'user.lock'
+    ) THEN
+        INSERT INTO `t_permission` (`id`, `company_id`, `name`, `value`, `pid`, `weight`)
+        VALUES (95, NULL, '锁定用户', 'user.lock', 18, 2);
+    END IF;
+
+    -- 2. 超管角色(id=1)补齐该权限(管道分隔权限字符串,幂等)
+    UPDATE `t_role`
+    SET `permissions` = CONCAT(IFNULL(`permissions`, ''), '|user.lock')
+    WHERE `id` = 1
+      AND FIND_IN_SET('user.lock', REPLACE(IFNULL(`permissions`, ''), '|', ',')) = 0;
+END //
+DELIMITER ;
+CALL add_user_lock_permission();
+DROP PROCEDURE IF EXISTS add_user_lock_permission;
+
+-- ====================================================
+-- 验证:
+--   SHOW COLUMNS FROM t_user LIKE 'lock_%';
+--   SELECT * FROM t_permission WHERE value = 'user.lock';
+--   SELECT permissions FROM t_role WHERE id = 1;
+-- ====================================================

+ 32 - 0
car-wash-entity/src/main/resources/sql/v24_add_user_lock_record.sql

@@ -0,0 +1,32 @@
+-- ====================================================
+-- v24: 用户锁定记录表
+-- 背景:锁定记录需要留痕,用户列表展示「锁定次数」,
+--       点击可查看每次锁定的备注/提示内容/操作人/时间及解锁信息。
+-- 说明:t_user 上的 lock_* 字段仅表示「当前锁定状态」(读取快、启动校验无需连表),
+--       完整历史以本表为准;解锁时关闭本表 status=1 的记录并清空 t_user 的 lock_* 字段。
+-- 依赖:需先执行 v23(t_user 锁定字段与 user.lock 权限)。可重复执行。
+-- ====================================================
+
+CREATE TABLE IF NOT EXISTS `t_user_lock_record` (
+    `id`              BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `company_id`      BIGINT DEFAULT NULL COMMENT '公司ID',
+    `user_id`         BIGINT NOT NULL COMMENT '用户ID',
+    `lock_remark`     VARCHAR(500) DEFAULT NULL COMMENT '锁定备注(仅平台人员可见,记录锁定原因)',
+    `lock_notice`     VARCHAR(500) DEFAULT NULL COMMENT '锁定提示内容(用户启动设备时强制弹出展示)',
+    `lock_operator`   VARCHAR(64)  DEFAULT NULL COMMENT '锁定操作人',
+    `lock_time`       DATETIME     DEFAULT NULL COMMENT '锁定时间',
+    `unlock_operator` VARCHAR(64)  DEFAULT NULL COMMENT '解锁操作人',
+    `unlock_time`     DATETIME     DEFAULT NULL COMMENT '解锁时间',
+    `status`          TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-锁定中,0-已解锁',
+    `create_time`     DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`     DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_user_status` (`user_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户锁定记录表';
+
+-- ====================================================
+-- 验证:
+--   SHOW COLUMNS FROM t_user_lock_record;
+--   SELECT * FROM t_user_lock_record WHERE user_id = ? ORDER BY lock_time DESC;
+-- ====================================================

+ 14 - 0
car-wash-mapper/src/main/java/com/kym/mapper/UserLockRecordMapper.java

@@ -0,0 +1,14 @@
+package com.kym.mapper;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.UserLockRecord;
+
+/**
+ * <p>
+ * 用户锁定记录表 Mapper 接口
+ * </p>
+ *
+ * @author skyline
+ */
+public interface UserLockRecordMapper extends MPJBaseMapper<UserLockRecord> {
+}

+ 3 - 2
car-wash-mapper/src/main/java/com/kym/mapper/UserMapper.java

@@ -2,8 +2,8 @@ package com.kym.mapper;
 
 
 import com.github.yulichang.base.MPJBaseMapper;
-import com.kym.entity.vo.CustomUserVo;
 import com.kym.entity.User;
+import com.kym.entity.vo.CustomUserVo;
 import com.kym.entity.vo.UserVo;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -34,7 +34,8 @@ public interface UserMapper extends MPJBaseMapper<User> {
             "  acc.grants_balance,\n" +
             "  acc.discount_amount,\n" +
             "  (acc.balance - acc.discount_amount) refundable_amount,\n" +
-            "  u.status \n" +
+            "  u.status,\n" +
+            "  u.lock_notice \n" +
             "FROM\n" +
             "  t_user u \n" +
             "  LEFT JOIN t_account acc \n" +

+ 22 - 3
car-wash-mapper/src/main/resources/mappers/UserMapper.xml

@@ -19,6 +19,10 @@
         <result column="update_time" property="updateTime"/>
         <result column="last_login_time" property="lastLoginTime"/>
         <result column="follow_official_account" property="followOfficialAccount"/>
+        <result column="lock_remark" property="lockRemark"/>
+        <result column="lock_notice" property="lockNotice"/>
+        <result column="lock_operator" property="lockOperator"/>
+        <result column="lock_time" property="lockTime"/>
     </resultMap>
 
     <resultMap id="CustomUserMap" type="com.kym.entity.vo.CustomUserVo">
@@ -37,11 +41,16 @@
         <result column="amount_received" property="amountReceived"/>
         <result column="discount_amount" property="discountAmount"/>
         <result column="follow_official_account" property="followOfficialAccount"/>
+        <result column="lock_remark" property="lockRemark"/>
+        <result column="lock_notice" property="lockNotice"/>
+        <result column="lock_operator" property="lockOperator"/>
+        <result column="lock_time" property="lockTime"/>
+        <result column="lock_times" property="lockTimes"/>
     </resultMap>
 
     <!-- 通用查询结果列 -->
     <sql id="Base_Column_List">
-        id, company_id,openid, unionid, username, password, gender, nickname, mobile_phone, avatar, status, create_time, update_time, last_login_time, follow_official_account
+        id, company_id,openid, unionid, username, password, gender, nickname, mobile_phone, avatar, status, create_time, update_time, last_login_time, follow_official_account, lock_remark, lock_notice, lock_operator, lock_time
     </sql>
 
 
@@ -62,7 +71,12 @@
             t2.amount_receivable,
             t2.amount_received,
             t2.discount_money,
-            t1.`follow_official_account`
+            t1.`follow_official_account`,
+            t1.`lock_remark`,
+            t1.`lock_notice`,
+            t1.`lock_operator`,
+            t1.`lock_time`,
+            (SELECT COUNT(1) FROM t_user_lock_record ulr WHERE ulr.user_id = t1.`id`) lock_times
             FROM
             t_user t1
             LEFT JOIN
@@ -119,7 +133,12 @@
             t1.amount_receivable,
             t1.amount_received,
             t1.discount_money,
-            user.`follow_official_account`
+            user.`follow_official_account`,
+            user.`lock_remark`,
+            user.`lock_notice`,
+            user.`lock_operator`,
+            user.`lock_time`,
+            (SELECT COUNT(1) FROM t_user_lock_record ulr WHERE ulr.user_id = user.`id`) lock_times
             FROM t_user `user`
             LEFT JOIN
             (SELECT user_id,

+ 1 - 0
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/WashDeviceController.java

@@ -37,6 +37,7 @@ public class WashDeviceController extends IController {
      * @param shortId
      * @return
      */
+    @SaIgnore
     @GetMapping(value = "/queryDevice/{shortId}")
     R<?> queryDevice(@PathVariable("shortId") String shortId) {
         return resp(() -> washDeviceService.getDevice(shortId));

+ 4 - 2
car-wash-mp/src/components/price-table/index.vue

@@ -3,8 +3,8 @@
     <view class="card-header">
       <view class="header-row">
         <view class="header-dot"></view>
-        <text class="header-title">收费标准<text class="header-tip">(以场地设备实际功能为准)</text></text>
-        <view class="header-close" @click="emit('close')">
+        <text class="header-title">服务价格说明<text class="header-tip">(以设备实际功能为准)</text></text>
+        <view class="header-close" @click.stop="emit('close')">
           <text class="close-icon">✕</text>
         </view>
       </view>
@@ -118,6 +118,8 @@ const fmtYuanPerMin = (fen: number) => {
       align-items: center;
       justify-content: center;
       flex-shrink: 0;
+      position: relative;
+      z-index: 10;
 
       .close-icon {
         font-size: 24rpx;

+ 228 - 4
car-wash-mp/src/pages-user/wallet/index.vue

@@ -132,6 +132,36 @@
         @click="to('/pages-user/wallet/recharge')"
       >充值</uv-button>
     </view>
+
+    <!-- 退款须知弹窗 -->
+    <uv-overlay :show="showRefundNotice" @click="showRefundNotice = false">
+      <view class="refund-modal" @click.stop>
+        <view class="refund-modal-header">
+          <view class="refund-modal-icon-wrapper">
+            <uv-icon name="kefu-ermai" color="#FFFFFF" size="40"></uv-icon>
+          </view>
+          <text class="refund-modal-title">联系客服退款</text>
+          <text class="refund-modal-subtitle">退款前请确认以下须知</text>
+        </view>
+        <view class="refund-modal-body">
+          <view class="refund-modal-steps">
+            <view class="refund-step" v-for="(step, i) in refundSteps" :key="i">
+              <view class="refund-step-icon">
+                <uv-icon :name="step.icon" color="#C6171E" size="17"></uv-icon>
+              </view>
+              <text class="refund-step-text">{{ step.text }}</text>
+            </view>
+          </view>
+          <view class="refund-modal-btns">
+            <button class="refund-modal-btn refund-modal-btn-contact" open-type="contact" @contact="onContactRefund">
+              <uv-icon name="kefu-ermai" color="#FFFFFF" size="18"></uv-icon>
+              <text>接入客服</text>
+            </button>
+            <button class="refund-modal-btn refund-modal-btn-cancel" @click="showRefundNotice = false">暂不退款</button>
+          </view>
+        </view>
+      </view>
+    </uv-overlay>
   </view>
 </template>
 
@@ -150,6 +180,14 @@ const account = ref({
 
 const dataList = ref<any[]>([]);
 const tab = ref(0);
+const showRefundNotice = ref(false);
+// 退款须知步骤
+const refundSteps = ref([
+  { icon: "phone", text: "将注册手机号发送给在线客服" },
+  { icon: "clock", text: "客服将在 3 个工作日内处理退款" },
+  { icon: "red-packet", text: "退款金额原路退回,请注意查收" },
+  { icon: "warning", text: "勿催!勿催!勿催!" },
+]);
 const tabs = ref([
   { name: "全部", value: 0 },
   { name: "充值", value: 1 },
@@ -158,10 +196,11 @@ const tabs = ref([
 ]);
 
 const handleRefund = () => {
-  uni.showToast({
-    icon: "none",
-    title: "请联系在线客服申请退款",
-  });
+  showRefundNotice.value = true;
+};
+
+const onContactRefund = () => {
+  showRefundNotice.value = false;
 };
 
 const getTypeIcon = (type: number) => {
@@ -577,4 +616,189 @@ const handleClickDetail = (walletDetail: any) => {
     height: 88rpx;
   }
 }
+
+.refund-modal {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 600rpx;
+  background: $uni-bg-color-card;
+  border-radius: 32rpx;
+  overflow: hidden;
+  z-index: 999;
+  box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.18);
+  animation: refund-modal-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;
+
+  .refund-modal-header {
+    position: relative;
+    overflow: hidden;
+    background: linear-gradient(135deg, $uni-color-primary 0%, $uni-color-primary-light 100%);
+    padding: 48rpx 40rpx 40rpx;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+
+    &::before,
+    &::after {
+      content: '';
+      position: absolute;
+      border-radius: 50%;
+      background: rgba(255, 255, 255, 0.08);
+    }
+
+    &::before {
+      top: -70rpx;
+      left: -50rpx;
+      width: 200rpx;
+      height: 200rpx;
+    }
+
+    &::after {
+      bottom: -90rpx;
+      right: -60rpx;
+      width: 240rpx;
+      height: 240rpx;
+    }
+  }
+
+  .refund-modal-icon-wrapper {
+    position: relative;
+    z-index: 1;
+    width: 96rpx;
+    height: 96rpx;
+    border-radius: 50%;
+    background: rgba(255, 255, 255, 0.22);
+    border: 2rpx solid rgba(255, 255, 255, 0.45);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
+  }
+
+  .refund-modal-title {
+    position: relative;
+    z-index: 1;
+    margin-top: 24rpx;
+    color: $uni-text-color-inverse;
+    font-size: 36rpx;
+    font-weight: $uni-font-weight-semibold;
+    letter-spacing: 2rpx;
+  }
+
+  .refund-modal-subtitle {
+    position: relative;
+    z-index: 1;
+    margin-top: 10rpx;
+    color: rgba(255, 255, 255, 0.85);
+    font-size: 24rpx;
+  }
+
+  .refund-modal-body {
+    padding: 32rpx 40rpx 40rpx;
+  }
+
+  .refund-modal-steps {
+    background: rgba($uni-color-primary, 0.04);
+    border: 1rpx solid rgba($uni-color-primary, 0.06);
+    border-radius: 20rpx;
+    padding: 28rpx 24rpx;
+    display: flex;
+    flex-direction: column;
+    gap: 24rpx;
+  }
+
+  .refund-step {
+    display: flex;
+    align-items: center;
+    gap: 20rpx;
+
+    .refund-step-icon {
+      flex-shrink: 0;
+      width: 56rpx;
+      height: 56rpx;
+      border-radius: 50%;
+      background: rgba($uni-color-primary, 0.08);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .refund-step-text {
+      flex: 1;
+      font-size: 26rpx;
+      color: $uni-text-color-secondary;
+      line-height: 1.6;
+    }
+  }
+
+  .refund-modal-btns {
+    margin-top: 32rpx;
+    display: flex;
+    flex-direction: column;
+    gap: 20rpx;
+
+    .refund-modal-btn {
+      width: 100%;
+      height: 88rpx;
+      border-radius: 44rpx;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 10rpx;
+      font-size: 30rpx;
+      border: none;
+      margin: 0;
+      padding: 0;
+      transition: transform 0.15s ease, opacity 0.15s ease;
+
+      &::after {
+        border: none;
+      }
+
+      &:active {
+        transform: scale(0.97);
+      }
+    }
+
+    .refund-modal-btn-contact {
+      background: linear-gradient(135deg, $uni-color-primary 0%, $uni-color-primary-light 100%);
+      color: $uni-text-color-inverse;
+      font-weight: $uni-font-weight-semibold;
+      box-shadow: 0 12rpx 24rpx rgba($uni-color-primary, 0.28);
+
+      &:active {
+        opacity: 0.9;
+        box-shadow: 0 6rpx 12rpx rgba($uni-color-primary, 0.22);
+      }
+    }
+
+    .refund-modal-btn-cancel {
+      background: $uni-bg-color-hover;
+      color: $uni-text-color-secondary;
+
+      &:active {
+        opacity: 0.7;
+      }
+    }
+  }
+}
+
+// 弹窗入场动画:轻微回弹
+@keyframes refund-modal-pop {
+  from {
+    opacity: 0;
+    transform: translate(-50%, -48%) scale(0.9);
+  }
+  to {
+    opacity: 1;
+    transform: translate(-50%, -50%) scale(1);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .refund-modal {
+    animation: none;
+  }
+}
 </style>

+ 88 - 4
car-wash-mp/src/pages-user/wallet/refund.vue

@@ -51,6 +51,20 @@
     </view>
 
 
+    <!-- 退款须知弹窗 -->
+    <uv-overlay :show="showNotice" @click="showNotice = false">
+      <view class="notice-modal" @click.stop>
+        <view class="notice-title">退款须知</view>
+        <view class="notice-content">
+          点击确定后,请发送您的注册手机号给客服,客服将在3个工作日内为您处理退款。退款金额将原路返回,请注意查收。
+        </view>
+        <view class="notice-btns">
+          <view class="notice-btn notice-btn-cancel" @click="showNotice = false">取消</view>
+          <button class="notice-btn notice-btn-confirm" open-type="contact" @contact="onContact">确定</button>
+        </view>
+      </view>
+    </uv-overlay>
+
     <uv-popup
         closeable
         @change="popClose"
@@ -83,6 +97,7 @@ const reasons = ref([
   "其他原因",
 ]);
 const success = ref(true);
+const showNotice = ref(false);
 
 
 onShow(() => {
@@ -112,9 +127,12 @@ const popClose = (e:any) => {
 }
 
 const submit = () => {
-  uni.showLoading({
-    title: "提交中",
-  });
+  // 显示退款须知弹窗
+  showNotice.value = true;
+};
+
+const onContact = () => {
+  // 用户点击联系客服后,提交退款申请
   let r = "";
   if (reason.value >= 0) {
     r += `${reasons.value[reason.value]}`;
@@ -125,8 +143,14 @@ const submit = () => {
   if (reasonText.value) {
     r += reasonText.value;
   }
+
+  uni.showLoading({
+    title: "提交中",
+  });
+
   post(`/payment/wxApplyRefund`, r).then((res: any) => {
     uni.hideLoading();
+    showNotice.value = false;
     success.value = true;
     popup_ref.value?.open();
   }).catch((err) => {
@@ -134,7 +158,7 @@ const submit = () => {
     uni.showModal({
       content: err.errMsg,
       showCancel: false
-    })
+    });
   });
 };
 
@@ -186,4 +210,64 @@ const close = () => {
   padding: 30rpx 40rpx;
   background-color: $uni-bg-color-card;
 }
+
+.notice-modal {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 600rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 48rpx 40rpx 32rpx;
+  z-index: 999;
+
+  .notice-title {
+    font-size: 36rpx;
+    font-weight: 500;
+    color: #000;
+    text-align: center;
+    margin-bottom: 32rpx;
+  }
+
+  .notice-content {
+    font-size: 28rpx;
+    color: #666;
+    line-height: 1.6;
+    margin-bottom: 48rpx;
+    text-align: left;
+  }
+
+  .notice-btns {
+    display: flex;
+    gap: 24rpx;
+
+    .notice-btn {
+      flex: 1;
+      height: 80rpx;
+      border-radius: 40rpx;
+      font-size: 30rpx;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border: none;
+      margin: 0;
+      padding: 0;
+
+      &::after {
+        border: none;
+      }
+    }
+
+    .notice-btn-cancel {
+      background: #f5f5f5;
+      color: #666;
+    }
+
+    .notice-btn-confirm {
+      background: #C6171E;
+      color: #fff;
+    }
+  }
+}
 </style>

+ 209 - 38
car-wash-mp/src/pages-wash/device/index.vue

@@ -30,14 +30,14 @@
       <view class="price-entry" v-if="state.device?.price" @click="pricePopupRef?.open()">
         <view class="price-entry-left">
           <text class="price-icon">¥</text>
-          <text class="price-text">收费标准</text>
+          <text class="price-text">服务价格说明</text>
         </view>
         <uv-icon name="arrow-right" size="16" color="#C0C4CC"></uv-icon>
       </view>
 
       <!-- 价格公示弹窗 -->
-      <uv-popup ref="pricePopupRef" mode="center" :round="0" :closeable="false" customStyle="background:transparent;">
-        <PriceTable :price="state.device?.price" @close="pricePopupRef?.close()" />
+      <uv-popup ref="pricePopupRef" mode="center" :round="0" :closeable="false" customStyle="background:transparent;" @close="handlePricePopupClose">
+        <PriceTable :price="state.device?.price" @close="handlePriceTableClose" />
       </uv-popup>
 
       <!-- 停车费提示 -->
@@ -120,6 +120,41 @@
     </scroll-view>
 
     <login-bar class="w100 text-center"></login-bar>
+
+    <!-- 启动设备确认弹窗 -->
+    <uv-popup ref="startConfirmPopupRef" mode="center" :round="16" :closeable="false" customStyle="width: 580rpx;">
+      <view class="start-confirm-modal" v-if="!showPriceInModal">
+        <view class="modal-title">提示</view>
+        <view class="modal-content">
+          <text>请确认您已了解并同意本设备的服务价格说明,确定启动设备开始洗车吗?</text>
+        </view>
+        <view class="modal-price-link" @click="showPriceInModal = true">
+          <text>查看《服务价格说明》</text>
+        </view>
+        <view class="modal-buttons">
+          <view class="modal-btn modal-btn-cancel" @click="startConfirmPopupRef?.close()">取消</view>
+          <view class="modal-btn modal-btn-confirm" @click="handleConfirmStartDevice">确定</view>
+        </view>
+      </view>
+      <view class="start-price-modal" v-else>
+        <view class="price-content">
+          <PriceTable :price="state.device?.price" @close="showPriceInModal = false" />
+        </view>
+      </view>
+    </uv-popup>
+
+    <!-- 账户锁定强制提示弹窗 -->
+    <uv-popup ref="lockNoticePopupRef" mode="center" :round="16" :closeable="false" customStyle="width: 580rpx;">
+      <view class="start-confirm-modal">
+        <view class="modal-title">账户已被锁定</view>
+        <view class="modal-content">
+          <text>{{ state.lockNotice || '您的账户已被锁定,如有疑问请联系平台客服' }}</text>
+        </view>
+        <view class="modal-buttons">
+          <view class="modal-btn modal-btn-confirm" @click="lockNoticePopupRef?.close()">我知道了</view>
+        </view>
+      </view>
+    </uv-popup>
   </view>
 </template>
 
@@ -141,6 +176,10 @@ const guideSteps = [
   '请及时将车辆驶离工位,方便后续用户使用'
 ]
 
+const startConfirmPopupRef = ref()
+const lockNoticePopupRef = ref()
+const showPriceInModal = ref(false) // 控制确认弹窗内显示确认内容还是价格说明
+
 const initState = () => ({
   isLogin:false,
   balance:0,
@@ -154,7 +193,8 @@ const initState = () => ({
   time: "00:00:00",
   start: new Date(),
   deviceId: null,
-  stationId: null
+  stationId: null,
+  lockNotice: ''
 })
 
 const state = reactive(initState())
@@ -182,6 +222,8 @@ onHide(() => {
   Object.assign(state, initState());
 })
 
+const deviceLoadedInOnLoad = ref(false);
+
 onLoad((options: any) => {
   let id = options?.shortId;
   state.stationId = options?.stationId;
@@ -198,10 +240,12 @@ onLoad((options: any) => {
   getApp<any>().globalData.deviceId = id;
   state.deviceId = id;
   state.device = getApp<any>().globalData.last.device;
+  // 立即加载设备详情(包含价格信息),无需登录即可获取
+  deviceLoadedInOnLoad.value = true;
+  loadDeviceDetail(id);
   checkLogin().then((token) => {
     state.isLogin =true;
     setTimeout(() => {
-      loadDeviceDetail(id);
       loadBalance()
     }, 200)
   }).catch(e => {
@@ -212,6 +256,22 @@ onLoad((options: any) => {
 
 onShow((options:any) => {
   addListener();
+  // 如果 onLoad 已经加载过设备详情,跳过首次 onShow 的加载
+  if (deviceLoadedInOnLoad.value) {
+    deviceLoadedInOnLoad.value = false;
+    checkLogin().then(() => {
+      state.isLogin = true;
+      loadBalance();
+    }).catch(e => {
+      console.error("校验登录失败,自动跳转登录页");
+      setTimeout(()=>{
+        uni.navigateTo({
+          url: `/pages-user/login/index?shortId=${!!state.deviceId?state.deviceId:''}&stationId=${!!state.stationId?state.stationId:''}&redirectUrl=/pages-wash/device/index`
+        })
+      },1000)
+    });
+    return;
+  }
   checkLogin().then(() => {
     if (!state.deviceId) {
       let deviceId = getApp<any>().globalData.deviceId;
@@ -293,47 +353,82 @@ const debounceStartStopDevice = debounce(() => {
   handleClickDevice();
 }, 600)
 
+// 弹出账户锁定强制提示
+const showLockNotice = (notice: string) => {
+  state.lockNotice = notice || ''
+  lockNoticePopupRef.value?.open()
+}
+
 const handleClickDevice = () => {
   if(!state.isLogin){
     return;
   }
-  uni.showModal({
-    title: '提示',
-    content: state.device?.state === 'idle' ? '确定启动设备开始洗车吗?' : '确定停止设备终止本次服务吗?',
-    success: (res) => {
-      if (res.confirm) {
-        getApp<any>().globalData.refresh =true;
-        if (state.device?.state === 'idle') {
-          uni.showLoading({
-            title: "启动中",
-            mask: true,
-          });
-          post(`/wash-device/startDevice/${state.device?.shortId}`).then((res: any) => {
-            uni.hideLoading();
-            uni.showToast({
-              title: '设备启动成功'
-            })
-            state.device.state = 'busy'
-          })
-        } else {
-          uni.showLoading({
-            title: "停止中",
-            mask: true,
-          });
-          post(`/wash-device/stopDevice/${state.device?.shortId}`).then((res: any) => {
-            uni.hideLoading();
-            uni.showToast({
-              title: '设备停机成功'
-            })
-            state.device.state = 'idle'
-            loadBalance()
-
-          })
+  // 停止设备使用简单确认框
+  if (state.device?.state !== 'idle') {
+    uni.showModal({
+      title: '提示',
+      content: '确定停止设备终止本次服务吗?',
+      success: (res) => {
+        if (res.confirm) {
+          doStopDevice()
         }
       }
-    }
+    });
+    return;
+  }
+  // 锁定用户禁止启动设备:直接弹出锁定提示(后端仍会硬拦截兜底)
+  const globalUser = getApp<any>().globalData.user || {}
+  if (globalUser.status === 0) {
+    showLockNotice(globalUser.lockNotice)
+    return
+  }
+  // 启动设备使用自定义弹窗(含服务价格说明链接)
+  showPriceInModal.value = false
+  startConfirmPopupRef.value?.open()
+}
+
+// 确认启动设备
+const handleConfirmStartDevice = () => {
+  startConfirmPopupRef.value?.close()
+  doStartDevice()
+}
+
+// 执行启动设备
+const doStartDevice = () => {
+  getApp<any>().globalData.refresh = true
+  uni.showLoading({
+    title: "启动中",
+    mask: true,
   });
+  post(`/wash-device/startDevice/${state.device?.shortId}`).then((res: any) => {
+    uni.hideLoading();
+    uni.showToast({
+      title: '设备启动成功'
+    })
+    state.device.state = 'busy'
+  }).catch((e: any) => {
+    uni.hideLoading();
+    // 账户被锁定:强制弹窗展示平台配置的提示内容
+    if (e && typeof e === 'object' && e.code === 10003) {
+      showLockNotice(e.message)
+    }
+  })
+}
 
+// 执行停止设备
+const doStopDevice = () => {
+  uni.showLoading({
+    title: "停止中",
+    mask: true,
+  });
+  post(`/wash-device/stopDevice/${state.device?.shortId}`).then((res: any) => {
+    uni.hideLoading();
+    uni.showToast({
+      title: '设备停机成功'
+    })
+    state.device.state = 'idle'
+    loadBalance()
+  })
 }
 
 const countTime = () => {
@@ -371,6 +466,14 @@ const handleGotoRechage = () => {
   })
 }
 
+const handlePriceTableClose = () => {
+  pricePopupRef.value?.close()
+}
+
+const handlePricePopupClose = () => {
+  // popup 关闭时的回调
+}
+
 </script>
 
 <style lang="scss" scoped>
@@ -835,4 +938,72 @@ const handleGotoRechage = () => {
     }
   }
 }
+
+// 启动设备确认弹窗
+.start-confirm-modal {
+  padding: 48rpx 40rpx 40rpx;
+
+  .modal-title {
+    font-size: 32rpx;
+    font-weight: $uni-font-weight-semibold;
+    color: $uni-text-color-dark;
+    text-align: center;
+    margin-bottom: 24rpx;
+  }
+
+  .modal-content {
+    font-size: 28rpx;
+    color: $uni-text-color-secondary;
+    line-height: 1.6;
+    text-align: center;
+    margin-bottom: 40rpx;
+  }
+
+  .modal-price-link {
+    text-align: center;
+    margin-bottom: 32rpx;
+
+    text {
+      font-size: 26rpx;
+      color: $uni-color-primary;
+      text-decoration: underline;
+    }
+  }
+
+  .modal-buttons {
+    display: flex;
+    gap: 16rpx;
+
+    .modal-btn {
+      flex: 1;
+      height: 80rpx;
+      line-height: 80rpx;
+      text-align: center;
+      border-radius: 40rpx;
+      font-size: 28rpx;
+      font-weight: $uni-font-weight-medium;
+    }
+
+    .modal-btn-cancel {
+      background: $uni-bg-color-page;
+      color: $uni-text-color-secondary;
+    }
+
+    .modal-btn-confirm {
+      background: $uni-color-primary;
+      color: #FFFFFF;
+    }
+  }
+}
+
+// 服务价格说明视图
+.start-price-modal {
+  .price-content {
+    :deep(.price-card) {
+      width: 100%;
+      border-radius: 0;
+      box-shadow: none;
+    }
+  }
+}
 </style>

+ 1 - 1
car-wash-mp/src/pages-wash/station/index.vue

@@ -29,7 +29,7 @@
       <view class="price-entry" v-if="unifiedPrice" @click="pricePopupRef?.open()">
         <view class="price-entry-left">
           <text class="price-icon">¥</text>
-          <text class="price-text">收费标准</text>
+          <text class="price-text">服务价格说明</text>
         </view>
         <uv-icon name="arrow-right" size="16" color="#C0C4CC"></uv-icon>
       </view>

+ 6 - 0
car-wash-mp/src/utils/https.ts

@@ -126,6 +126,12 @@ const request = (options: any) => {
             if (response.code !== 200) {
                 let errMsg = response.message || '服务异常,请稍后重试';
                 console.error("error=>", errMsg);
+                // 账户被锁定:跳过通用toast,由业务页弹强制提示弹窗(避免重复提示)
+                // 同时带上 errMsg,兼容既有 catch 中读取 err.errMsg 的调用方
+                if (response.code === 10003) {
+                    reject({ code: response.code, message: errMsg, errMsg: errMsg });
+                    return;
+                }
                 if (response.code !== 301) {
                     uni.showToast({
                         title: errMsg,

+ 24 - 0
car-wash-service/src/main/java/com/kym/service/UserLockRecordService.java

@@ -0,0 +1,24 @@
+package com.kym.service;
+
+import com.github.yulichang.base.MPJBaseService;
+import com.kym.entity.UserLockRecord;
+
+import java.util.List;
+
+/**
+ * <p>
+ * 用户锁定记录表 服务类
+ * </p>
+ *
+ * @author skyline
+ */
+public interface UserLockRecordService extends MPJBaseService<UserLockRecord> {
+
+    /**
+     * 查询指定用户的锁定记录(按锁定时间倒序)
+     *
+     * @param userId 用户id
+     * @return 锁定记录
+     */
+    List<UserLockRecord> listByUserId(Long userId);
+}

+ 16 - 0
car-wash-service/src/main/java/com/kym/service/UserService.java

@@ -31,6 +31,22 @@ public interface UserService extends MPJBaseService<User> {
 
     PageBean<CustomUserVo> listCustomUser(CommonQueryParam params);
 
+    /**
+     * 锁定用户:禁用登录态下的设备启动,并记录锁定原因与提示内容
+     *
+     * @param userId     用户id
+     * @param lockRemark 锁定备注(仅平台人员可见,可空)
+     * @param lockNotice 锁定提示内容(用户启动设备时强制弹出,必填)
+     */
+    void lockUser(Long userId, String lockRemark, String lockNotice);
+
+    /**
+     * 解锁用户:恢复使用,并清空锁定信息
+     *
+     * @param userId 用户id
+     */
+    void unlockUser(Long userId);
+
     Map<String, Integer> countDailyRegister(LocalDate statDay, String... stationId);
 
     Map<String, Integer> countMonthRegister(LocalDate statDay, String... stationId);

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

@@ -0,0 +1,28 @@
+package com.kym.service.impl;
+
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.kym.entity.UserLockRecord;
+import com.kym.mapper.UserLockRecordMapper;
+import com.kym.service.UserLockRecordService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * <p>
+ * 用户锁定记录表 服务实现类
+ * </p>
+ *
+ * @author skyline
+ */
+@Service
+public class UserLockRecordServiceImpl extends MPJBaseServiceImpl<UserLockRecordMapper, UserLockRecord> implements UserLockRecordService {
+
+    @Override
+    public List<UserLockRecord> listByUserId(Long userId) {
+        return lambdaQuery()
+                .eq(UserLockRecord::getUserId, userId)
+                .orderByDesc(UserLockRecord::getLockTime)
+                .list();
+    }
+}

+ 82 - 1
car-wash-service/src/main/java/com/kym/service/impl/UserServiceImpl.java

@@ -26,6 +26,7 @@ import com.kym.mapper.UserMapper;
 import com.kym.service.AccountService;
 import com.kym.service.CarsService;
 import com.kym.service.RefundLogService;
+import com.kym.service.UserLockRecordService;
 import com.kym.service.UserService;
 import com.kym.service.cache.KymCache;
 import jakarta.annotation.PostConstruct;
@@ -65,15 +66,18 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
     private final RefundLogService refundLogService;
     private final CarsService carsService;
     private final MpRelationMapper mpRelationMapper;
+    private final UserLockRecordService userLockRecordService;
 
 
     public UserServiceImpl(WxConfig wxConfig, @Lazy AccountService accountService, RefundLogService refundLogService,
-                           CarsService carsService, MpRelationMapper mpRelationMapper) {
+                           CarsService carsService, MpRelationMapper mpRelationMapper,
+                           UserLockRecordService userLockRecordService) {
         this.wxConfig = wxConfig;
         this.accountService = accountService;
         this.refundLogService = refundLogService;
         this.carsService = carsService;
         this.mpRelationMapper = mpRelationMapper;
+        this.userLockRecordService = userLockRecordService;
     }
 
     @PostConstruct
@@ -233,6 +237,22 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
         userVo.setId(StpUtil.getSession().getLong("userId"));
         var user = new User();
         BeanUtils.copyProperties(userVo, user);
+        // 安全加固:本接口只允许用户更新自身资料(昵称/头像/手机号/性别),
+        // 其余字段一律忽略,防止越权自更新:
+        // 1) 账户状态与锁定信息只允许平台侧锁定/解锁接口变更;
+        // 2) openid/unionid 可被改写后重新注册新账号,绕过锁定;
+        // 3) stationId 影响充值分账与月结归属;username/lastLoginTime/companyId 为服务端字段。
+        user.setStatus(null);
+        user.setLockRemark(null);
+        user.setLockNotice(null);
+        user.setLockOperator(null);
+        user.setLockTime(null);
+        user.setOpenid(null);
+        user.setUnionid(null);
+        user.setStationId(null);
+        user.setUsername(null);
+        user.setLastLoginTime(null);
+        user.setCompanyId(null);
         if (!CommUtil.isEmptyOrNull(user.getAvatar()) || !CommUtil.isEmptyOrNull(user.getMobilePhone()) || !CommUtil.isEmptyOrNull(user.getNickname())) {
             baseMapper.updateById(user);
         }
@@ -316,6 +336,67 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
     }
 
 
+    /**
+     * 锁定用户:禁用登录态下的设备启动,并记录锁定原因与提示内容
+     *
+     * @param userId     用户id
+     * @param lockRemark 锁定备注(仅平台人员可见,可空)
+     * @param lockNotice 锁定提示内容(用户启动设备时强制弹出,必填)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void lockUser(Long userId, String lockRemark, String lockNotice) {
+        CommUtil.asserts(CommUtil.isNotEmptyAndNull(lockNotice), "请填写锁定提示内容");
+        CommUtil.assertsNonNull(getById(userId), "用户不存在", ResponseEnum.PARAMS_ERROR.getCode());
+        var operator = StpUtil.getSession().getString("username");
+        var lockTime = LocalDateTime.now();
+        lambdaUpdate()
+                .set(User::getStatus, User.STATUS_禁用)
+                .set(User::getLockRemark, lockRemark)
+                .set(User::getLockNotice, lockNotice)
+                .set(User::getLockOperator, operator)
+                .set(User::getLockTime, lockTime)
+                .eq(User::getId, userId)
+                .update();
+        // 留痕:每次锁定写入一条锁定记录
+        userLockRecordService.save(new UserLockRecord()
+                .setUserId(userId)
+                .setLockRemark(lockRemark)
+                .setLockNotice(lockNotice)
+                .setLockOperator(operator)
+                .setLockTime(lockTime)
+                .setStatus(UserLockRecord.STATUS_锁定中));
+    }
+
+    /**
+     * 解锁用户:恢复使用,并清空锁定信息
+     *
+     * @param userId 用户id
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void unlockUser(Long userId) {
+        CommUtil.assertsNonNull(userId, "用户id不能为空", ResponseEnum.PARAMS_ERROR.getCode());
+        CommUtil.assertsNonNull(getById(userId), "用户不存在", ResponseEnum.PARAMS_ERROR.getCode());
+        lambdaUpdate()
+                .set(User::getStatus, User.STATUS_启用)
+                .set(User::getLockRemark, null)
+                .set(User::getLockNotice, null)
+                .set(User::getLockOperator, null)
+                .set(User::getLockTime, null)
+                .eq(User::getId, userId)
+                .update();
+        // 关闭该用户所有进行中的锁定记录(历史记录保留,仅回填解锁信息)
+        userLockRecordService.lambdaUpdate()
+                .set(UserLockRecord::getStatus, UserLockRecord.STATUS_已解锁)
+                .set(UserLockRecord::getUnlockOperator, StpUtil.getSession().getString("username"))
+                .set(UserLockRecord::getUnlockTime, LocalDateTime.now())
+                .eq(UserLockRecord::getUserId, userId)
+                .eq(UserLockRecord::getStatus, UserLockRecord.STATUS_锁定中)
+                .update();
+    }
+
+
     /**
      * 统计指定日期注册用户数量
      *

+ 11 - 2
car-wash-service/src/main/java/com/kym/service/impl/WashDeviceServiceImpl.java

@@ -102,6 +102,14 @@ public class WashDeviceServiceImpl extends MyBaseServiceImpl<WashDeviceMapper, W
     @Override
     public WashDeviceVo getDevice(String shortId) {
         var productKeyAndDeviceName = KymCache.INSTANCE.getProductKeyAndDeviceNameByWashShortId(shortId);
+        // 获取当前登录用户ID(可能为null,表示未登录)
+        Long currentUserId = null;
+        try {
+            currentUserId = StpUtil.getLoginIdAsLong();
+        } catch (Exception ignored) {
+            // 未登录状态,currentUserId 保持为 null
+        }
+
         // 校验操作人权限
         var order = washOrderService.lambdaQuery()
                 .eq(WashOrder::getProductKey, productKeyAndDeviceName[0])
@@ -110,13 +118,14 @@ public class WashDeviceServiceImpl extends MyBaseServiceImpl<WashDeviceMapper, W
                 .eq(WashOrder::getPayStatus, WashOrder.PAY_STATUS_未支付)
                 .one();
 
-        if (order == null || order.getUserId() == StpUtil.getLoginIdAsLong()) {
+        // 未登录用户或设备空闲或当前用户正在使用,允许访问
+        if (order == null || currentUserId == null || order.getUserId().equals(currentUserId)) {
             var washDevice = lambdaQuery()
                     .eq(WashDevice::getProductKey, productKeyAndDeviceName[0])
                     .eq(WashDevice::getDeviceName, productKeyAndDeviceName[1])
                     .one();
 
-            var vo = new WashDeviceVo().setCurrentUserId(StpUtil.getLoginIdAsLong());
+            var vo = new WashDeviceVo().setCurrentUserId(currentUserId);
             BeanUtils.copyProperties(washDevice, vo.setShortId(shortId));
 
             if (washDevice != null && washDevice.getDeviceConfigId() != null) {

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

@@ -6,6 +6,7 @@ import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import com.github.yulichang.toolkit.JoinWrappers;
 import com.github.yulichang.wrapper.MPJLambdaWrapper;
+import com.kym.common.constant.ResponseEnum;
 import com.kym.common.exception.BusinessException;
 import com.kym.common.utils.CommUtil;
 import com.kym.common.utils.OrderUtils;
@@ -89,6 +90,14 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
      */
     @Override
     public String createOrder(DeviceQueryParams params) {
+        // 校验用户是否被锁定(锁定用户禁止启动设备,提示内容透传给用户端强制弹窗展示)
+        var lockCheckUser = userService.getById(StpUtil.getLoginIdAsLong());
+        if (lockCheckUser != null && lockCheckUser.getStatus() != null && lockCheckUser.getStatus() == User.STATUS_禁用) {
+            throw new BusinessException(ResponseEnum.USER_LOCKED.getCode(),
+                    CommUtil.isNotEmptyAndNull(lockCheckUser.getLockNotice()) ? lockCheckUser.getLockNotice()
+                            : "您的账户已被锁定,如有疑问请联系平台客服");
+        }
+
         // 校验余额
         var account = accountService.getAccountByUserId(StpUtil.getLoginIdAsLong());
         if (account.getBalance() < Account.MIN_BALANCE) {

+ 24 - 0
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -218,6 +218,8 @@ public class WxPayServiceImpl implements WxPayService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId) {
+        // 锁定用户禁止充值(避免充值后因退款被拦导致资金无法退出)
+        assertUserNotLocked(StpUtil.getLoginIdAsLong(), "该用户已被锁定,禁止充值");
         // 充值配置
         var rechargeConfig = rechargeConfigService.getById(rechargeConfigId);
 
@@ -268,6 +270,8 @@ public class WxPayServiceImpl implements WxPayService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public PrepayWithRequestPaymentResponse promotionPay(String promotionToken) {
+        // 锁定用户禁止充值(避免充值后因退款被拦导致资金无法退出)
+        assertUserNotLocked(StpUtil.getLoginIdAsLong(), "该用户已被锁定,禁止充值");
         var promotion = rechargePromotionService.getByToken(promotionToken);
         if (promotion == null) {
             throw new BusinessException("优惠活动不存在或已过期");
@@ -515,6 +519,19 @@ public class WxPayServiceImpl implements WxPayService {
         LOGGER.info("线下充值完成:userId={}, amount={}, grantsAmount={}, tradeNo={}", userId, amount, gAmount, tradeNo);
     }
 
+    /**
+     * 校验用户未被锁定(锁定用户禁止申请与处理退款)
+     *
+     * @param userId       用户id
+     * @param errorMessage 锁定时的错误提示
+     */
+    private void assertUserNotLocked(Long userId, String errorMessage) {
+        var user = userService.getById(userId);
+        if (user != null && user.getStatus() != null && user.getStatus() == User.STATUS_禁用) {
+            throw new BusinessException(ResponseEnum.USER_LOCKED.getCode(), errorMessage);
+        }
+    }
+
     /**
      * 申请退款
      */
@@ -522,6 +539,8 @@ public class WxPayServiceImpl implements WxPayService {
     @Transactional(rollbackFor = Exception.class)
     public void applyWxRefund(Long userId, String reason) {
 //        var userId = StpUtil.getLoginIdAsLong();
+        // 锁定用户禁止申请退款
+        assertUserNotLocked(userId, "该用户已被锁定,禁止申请退款");
         var washOrder = washOrderService.getOrderInProgressByUserId(userId);
         // 洗车未完结订单校验
         if (washOrder != null) {
@@ -634,6 +653,11 @@ public class WxPayServiceImpl implements WxPayService {
     public void wxRefund(long refundLogId) {
         // 通过退款申请id获取退款申请记录
         var refundLog = refundLogService.getById(refundLogId);
+        if (refundLog == null) {
+            throw new BusinessException("退款申请记录不存在");
+        }
+        // 锁定用户禁止处理退款(含锁定前已提交的存量退款)
+        assertUserNotLocked(refundLog.getUserId(), "该用户已被锁定,禁止处理退款");
 
         // 钱包流水
         var walletDetail = new WalletDetail()