Browse Source

feat: 添加设备故障通知功能

新增故障类型枚举、故障记录和订阅者实体,支持设备状态/故障事件处理,
故障订阅管理和定时通知推送,包含故障页面路由和微信消息模板。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 4 days ago
parent
commit
d4de9ee036
26 changed files with 1391 additions and 16 deletions
  1. 16 1
      admin-web/src/router/route.ts
  2. 339 0
      admin-web/src/views/admin/station/fault/index.vue
  3. 99 0
      car-wash-admin/src/main/java/com/kym/admin/controller/FaultSubscriberController.java
  4. 34 0
      car-wash-admin/src/main/java/com/kym/admin/jobs/FaultNotificationJob.java
  5. 37 0
      car-wash-common/src/main/java/com/kym/common/enums/FaultType.java
  6. 3 1
      car-wash-common/src/main/java/com/kym/common/enums/MsgTemplateType.java
  7. 73 0
      car-wash-entity/src/main/java/com/kym/entity/FaultRecord.java
  8. 55 0
      car-wash-entity/src/main/java/com/kym/entity/FaultSubscriber.java
  9. 1 0
      car-wash-entity/src/main/java/com/kym/entity/common/RedisKeys.java
  10. 23 0
      car-wash-entity/src/main/resources/sql/v3_notice_test_data.sql
  11. 13 0
      car-wash-entity/src/main/resources/sql/v3_wash_station.sql
  12. 2 0
      car-wash-entity/src/main/resources/sql/v4_pa_device.sql
  13. 2 0
      car-wash-entity/src/main/resources/sql/v5_wash_device_sequence.sql
  14. 108 0
      car-wash-entity/src/main/resources/sql/v6_fault_notification.sql
  15. 9 0
      car-wash-mapper/src/main/java/com/kym/mapper/FaultRecordMapper.java
  16. 9 0
      car-wash-mapper/src/main/java/com/kym/mapper/FaultSubscriberMapper.java
  17. 38 0
      car-wash-service/src/main/java/com/kym/service/FaultNotificationService.java
  18. 27 0
      car-wash-service/src/main/java/com/kym/service/FaultRecordService.java
  19. 40 0
      car-wash-service/src/main/java/com/kym/service/FaultSubscriberService.java
  20. 56 9
      car-wash-service/src/main/java/com/kym/service/awoara/event/handle/DeviceStateEventHandler.java
  21. 9 2
      car-wash-service/src/main/java/com/kym/service/awoara/event/handle/DeviceStatusEventHandler.java
  22. 6 2
      car-wash-service/src/main/java/com/kym/service/awoara/factory/AwoaraEventHandlerFactory.java
  23. 220 0
      car-wash-service/src/main/java/com/kym/service/impl/FaultNotificationServiceImpl.java
  24. 43 0
      car-wash-service/src/main/java/com/kym/service/impl/FaultRecordServiceImpl.java
  25. 78 0
      car-wash-service/src/main/java/com/kym/service/impl/FaultSubscriberServiceImpl.java
  26. 51 1
      car-wash-service/src/main/java/com/kym/service/wechat/impl/WeixinMPServiceImpl.java

+ 16 - 1
admin-web/src/router/route.ts

@@ -115,7 +115,7 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                     isAffix: false,
                     isAffix: false,
                     isIframe: false,
                     isIframe: false,
                     icon: 'ele-MapLocation',
                     icon: 'ele-MapLocation',
-                    perm: "equipment.list,station.list,stationStatMonth.list,statement.list",
+                    perm: "equipment.list,station.list,stationStatMonth.list,statement.list,faultSubscriber.list,faultRecord.list",
                 },
                 },
                 children: [
                 children: [
                     {
                     {
@@ -148,6 +148,21 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                             icon: 'ele-User',
                             icon: 'ele-User',
                         },
                         },
                     },
                     },
+                    {
+                        path: '/station/fault',
+                        name: 'adminStationFault',
+                        component: () => import('/@/views/admin/station/fault/index.vue'),
+                        meta: {
+                            title: '故障订阅',
+                            isLink: '',
+                            isHide: false,
+                            isKeepAlive: true,
+                            isAffix: false,
+                            isIframe: false,
+                            perm: "faultSubscriber.list",
+                            icon: 'ele-Bell',
+                        },
+                    },
 
 
                 ]
                 ]
             },
             },

+ 339 - 0
admin-web/src/views/admin/station/fault/index.vue

@@ -0,0 +1,339 @@
+<style scoped lang="scss">
+.system-container {
+  :deep(.el-card__body) {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    flex: 1;
+    overflow: auto;
+
+    .el-table {
+      flex: 1;
+    }
+  }
+}
+
+.page-content {
+  margin-bottom: 20px;
+}
+
+.page-pager {
+  background-color: var(--el-color-white);
+  height: 24px;
+}
+
+.qrcode-img {
+  width: 260px;
+  height: 260px;
+  margin: 20px auto;
+  display: block;
+  border: 1px solid #eee;
+}
+
+.qrcode-tip {
+  text-align: center;
+  color: #999;
+  font-size: 13px;
+  margin-top: 10px;
+}
+
+.fault-tag {
+  margin-right: 8px;
+}
+</style>
+<template>
+  <div class="system-container layout-padding">
+    <el-card shadow="hover" class="layout-padding-auto">
+      <el-tabs v-model="state.activeTab" @tab-click="handleTabClick">
+        <!-- ==================== 订阅管理 ==================== -->
+        <el-tab-pane label="订阅管理" name="subscriber">
+          <el-form :model="state.formQuery" ref="queryRef" size="default" label-width="0px" class="mt5 mb5">
+            <ext-select
+                v-model="state.formQuery.stationId"
+                placeholder="请选择站点"
+                url="washStation/list"
+                url-method="post"
+                label-key="stationName"
+                value-key="stationId"
+                data-key="list"
+                clearable
+                class="wd200"
+                @change="loadSubscribers(true)"/>
+            <el-button class="ml10" plain size="default" type="success" @click="loadSubscribers(true)">
+              <SvgIcon name="ele-Search"/>
+              查询
+            </el-button>
+            <el-button v-if="state.formQuery.stationId" class="ml10" plain size="default" type="primary" @click="handleGenerateQrcode">
+              <SvgIcon name="ele-Picture"/>
+              生成绑定二维码
+            </el-button>
+          </el-form>
+
+          <el-table
+              border stripe
+              :height="state.tableHeight"
+              :data="state.subscriberData"
+              v-loading="state.subscriberLoading">
+            <template #empty>
+              <el-empty description="请选择站点后查询"/>
+            </template>
+            <el-table-column label="OpenID" prop="openid" width="260" show-overflow-tooltip/>
+            <el-table-column label="昵称" prop="nickname" width="150"/>
+            <el-table-column label="站点" prop="stationId" width="120"/>
+            <el-table-column label="绑定时间" prop="subscribeTime" width="170">
+              <template #default="{row}">{{ u.fmt.fmtDateTime(row.subscribeTime) }}</template>
+            </el-table-column>
+            <el-table-column label="状态" prop="status" width="90">
+              <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="100" align="center" fixed="right">
+              <template #default="{row}">
+                <el-button v-if="row.status === 1" type="danger" text size="small" @click="handleUnsubscribe(row)">解绑</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-tab-pane>
+
+        <!-- ==================== 故障记录 ==================== -->
+        <el-tab-pane label="故障记录" name="faultRecord">
+          <el-form :model="state.faultQuery" size="default" label-width="0px" class="mt5 mb5">
+            <ext-select
+                v-model="state.faultQuery.stationId"
+                placeholder="站点(可选)"
+                url="washStation/list"
+                url-method="post"
+                label-key="stationName"
+                value-key="stationId"
+                data-key="list"
+                clearable
+                class="wd200"/>
+            <el-select v-model="state.faultQuery.faultType" placeholder="故障类型" clearable class="wd150 ml10">
+              <el-option label="设备离线" value="offline"/>
+              <el-option label="缺水" value="water_shortage"/>
+              <el-option label="缺泡沫" value="foam_shortage"/>
+            </el-select>
+            <el-select v-model="state.faultQuery.isRecovered" placeholder="恢复状态" clearable class="wd150 ml10">
+              <el-option label="未恢复" :value="0"/>
+              <el-option label="已恢复" :value="1"/>
+            </el-select>
+            <el-button class="ml10" plain size="default" type="success" @click="loadFaultRecords(true)">
+              <SvgIcon name="ele-Search"/>
+              查询
+            </el-button>
+          </el-form>
+
+          <el-table
+              border stripe
+              :height="state.tableHeight"
+              :data="state.faultRecordData"
+              v-loading="state.faultRecordLoading">
+            <template #empty>
+              <el-empty description="暂无故障记录"/>
+            </template>
+            <el-table-column label="站点" prop="stationId" width="100"/>
+            <el-table-column label="设备" prop="deviceName" width="150"/>
+            <el-table-column label="故障类型" prop="faultType" width="110">
+              <template #default="{row}">
+                <el-tag class="fault-tag"
+                    :type="row.faultType === 'offline' ? 'danger' : 'warning'"
+                    size="small">
+                  {{ faultTypeLabel(row.faultType) }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="故障时间" prop="faultTime" width="170">
+              <template #default="{row}">{{ u.fmt.fmtDateTime(row.faultTime) }}</template>
+            </el-table-column>
+            <el-table-column label="是否通知" prop="isNotified" width="90">
+              <template #default="{row}">
+                <el-tag :type="row.isNotified === 1 ? 'success' : 'warning'" size="small">
+                  {{ row.isNotified === 1 ? '已通知' : '未通知' }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="通知时间" prop="notifyTime" width="170">
+              <template #default="{row}">{{ row.notifyTime ? u.fmt.fmtDateTime(row.notifyTime) : '-' }}</template>
+            </el-table-column>
+            <el-table-column label="是否恢复" prop="isRecovered" width="90">
+              <template #default="{row}">
+                <el-tag :type="row.isRecovered === 1 ? 'success' : 'danger'" size="small">
+                  {{ row.isRecovered === 1 ? '已恢复' : '未恢复' }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="恢复时间" prop="recoverTime" width="170">
+              <template #default="{row}">{{ row.recoverTime ? u.fmt.fmtDateTime(row.recoverTime) : '-' }}</template>
+            </el-table-column>
+          </el-table>
+
+          <ext-page class="page-pager" v-model:value="state.faultPageQuery" @change="loadFaultRecords(false)"/>
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+
+    <!-- 二维码弹窗 -->
+    <el-dialog v-model="state.qrcodeDialogVisible" title="故障通知绑定二维码" width="420px" center>
+      <div style="text-align: center">
+        <img v-if="state.qrcodeUrl" :src="state.qrcodeUrl" class="qrcode-img" alt="绑定二维码"/>
+        <div class="qrcode-tip">
+          请使用微信扫描二维码<br/>
+          扫描后关注公众号即可绑定,再次扫描可解绑<br/>
+          站点:{{ state.formQuery.stationId }}
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="adminStationFault">
+import {reactive, onMounted, onBeforeMount, ref, nextTick, onBeforeUnmount} from 'vue';
+import {$body, $get} from "/@/utils/request";
+import u from '/@/utils/u'
+import {Msg} from "/@/utils/message";
+
+import ExtPage from '/@/components/form/ExtPage.vue'
+import ExtSelect from "/@/components/form/ExtSelect.vue";
+import mittBus from '/@/utils/mitt';
+
+
+const state = reactive({
+  activeTab: 'subscriber',
+  tableHeight: 400,
+  formQuery: {
+    stationId: '' as string
+  },
+  subscriberData: [] as Array<any>,
+  subscriberLoading: false,
+
+  faultQuery: {
+    stationId: '' as string,
+    faultType: '' as string,
+    isRecovered: null as number | null
+  },
+  faultRecordData: [] as Array<any>,
+  faultRecordLoading: false,
+  faultPageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+
+  qrcodeDialogVisible: false,
+  qrcodeUrl: '',
+  qrcodeTicket: ''
+});
+
+const faultTypeLabel = (type: string) => {
+  switch (type) {
+    case 'offline': return '设备离线';
+    case 'water_shortage': return '缺水';
+    case 'foam_shortage': return '缺泡沫';
+    default: return type;
+  }
+};
+
+const handleTabClick = () => {
+  if (state.activeTab === 'faultRecord') {
+    loadFaultRecords(true);
+  }
+};
+
+// ============ 订阅管理 ============
+
+const loadSubscribers = (refresh: boolean = false) => {
+  if (!state.formQuery.stationId) {
+    state.subscriberData = [];
+    return;
+  }
+  state.subscriberLoading = true;
+  $get('/faultSubscriber/list', { stationId: state.formQuery.stationId }).then((res: any) => {
+    state.subscriberData = res.data || res || [];
+    state.subscriberLoading = false;
+  }).catch(() => {
+    state.subscriberLoading = false;
+  });
+};
+
+const handleGenerateQrcode = () => {
+  if (!state.formQuery.stationId) {
+    Msg.message('请先选择站点', 'warning');
+    return;
+  }
+  Msg.showLoading('生成中...');
+  $body('/faultSubscriber/generateQrcode', { stationId: state.formQuery.stationId }).then((res: any) => {
+    Msg.hideLoading();
+    const data = res.data || res;
+    state.qrcodeUrl = data.url;
+    state.qrcodeTicket = data.ticket;
+    state.qrcodeDialogVisible = true;
+  }).catch(() => {
+    Msg.hideLoading();
+    Msg.message('生成二维码失败', 'error');
+  });
+};
+
+const handleUnsubscribe = (row: any) => {
+  Msg.confirm(`确定要解绑 ${row.openid} 吗?解绑后该用户将不再接收故障通知。`).then(() => {
+    $body('/faultSubscriber/unsubscribe', { openid: row.openid, stationId: row.stationId }).then(() => {
+      Msg.message('解绑成功', 'success');
+      loadSubscribers(false);
+    }).catch(() => {
+      Msg.message('解绑失败', 'error');
+    });
+  }).catch(() => {});
+};
+
+// ============ 故障记录 ============
+
+const loadFaultRecords = (refresh: boolean = false) => {
+  if (refresh) {
+    state.faultPageQuery.pageNum = 1;
+  }
+  state.faultRecordLoading = true;
+  const params: any = { ...state.faultPageQuery };
+  if (state.faultQuery.stationId) params.stationId = state.faultQuery.stationId;
+  if (state.faultQuery.faultType) params.faultType = state.faultQuery.faultType;
+  if (state.faultQuery.isRecovered !== null && state.faultQuery.isRecovered !== undefined) {
+    params.isRecovered = state.faultQuery.isRecovered;
+  }
+  $get('/faultSubscriber/faultRecords', params).then((res: any) => {
+    const list = res.data || res || [];
+    if (Array.isArray(list)) {
+      state.faultRecordData = list;
+      state.faultPageQuery.total = list.length;
+    } else if (list.list) {
+      state.faultRecordData = list.list;
+      state.faultPageQuery.total = list.total || 0;
+    } else {
+      state.faultRecordData = list;
+    }
+    state.faultRecordLoading = false;
+  }).catch(() => {
+    state.faultRecordLoading = false;
+  });
+};
+
+// ============ 生命周期 ============
+
+onBeforeMount(() => {
+});
+
+onMounted(() => {
+  nextTick(() => {
+    let bodyHeight = document.body.clientHeight;
+    state.tableHeight = bodyHeight - 280;
+  });
+
+  mittBus.on("faultSubscriber.refresh", () => {
+    loadSubscribers(false);
+  });
+});
+
+onBeforeUnmount(() => {
+  mittBus.off("faultSubscriber.refresh");
+});
+</script>

+ 99 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/FaultSubscriberController.java

@@ -0,0 +1,99 @@
+package com.kym.admin.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.kym.common.R;
+import com.kym.entity.FaultRecord;
+import com.kym.entity.FaultSubscriber;
+import com.kym.service.FaultRecordService;
+import com.kym.service.FaultSubscriberService;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.mp.api.WxMpService;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 故障通知订阅管理
+ *
+ * @author skyline
+ */
+@Slf4j
+@RestController
+@RequestMapping("/faultSubscriber")
+public class FaultSubscriberController {
+
+    private final FaultSubscriberService faultSubscriberService;
+    private final FaultRecordService faultRecordService;
+    private final WxMpService wxMpService;
+
+    public FaultSubscriberController(FaultSubscriberService faultSubscriberService,
+                                      FaultRecordService faultRecordService,
+                                      WxMpService wxMpService) {
+        this.faultSubscriberService = faultSubscriberService;
+        this.faultRecordService = faultRecordService;
+        this.wxMpService = wxMpService;
+    }
+
+    /**
+     * 获取站点的订阅者列表
+     */
+    @SaCheckPermission("faultSubscriber.list")
+    @GetMapping("/list")
+    R<?> list(@RequestParam String stationId) {
+        var list = faultSubscriberService.lambdaQuery()
+                .eq(FaultSubscriber::getStationId, stationId)
+                .eq(FaultSubscriber::getStatus, FaultSubscriber.STATUS_已订阅)
+                .orderByDesc(FaultSubscriber::getSubscribeTime)
+                .list();
+        return R.success(list);
+    }
+
+    /**
+     * 管理员手动解绑订阅者
+     */
+    @SaCheckPermission("faultSubscriber.delete")
+    @PostMapping("/unsubscribe")
+    R<?> unsubscribe(@RequestBody Map<String, String> params) {
+        var openid = params.get("openid");
+        var stationId = params.get("stationId");
+        faultSubscriberService.unsubscribe(openid, stationId);
+        return R.success();
+    }
+
+    /**
+     * 生成站点故障通知绑定二维码(公众号带参临时二维码)
+     * scene_str = stationId, 有效期30天
+     */
+    @SaCheckPermission("faultSubscriber.qrcode")
+    @PostMapping("/generateQrcode")
+    R<?> generateQrcode(@RequestBody Map<String, String> params) throws WxErrorException {
+        var stationId = params.get("stationId");
+        // 生成带scene_str的临时二维码,有效期30天 (最大)
+        var ticket = wxMpService.getQrcodeService().qrCodeCreateTmpTicket(stationId, 2592000);
+        var url = wxMpService.getQrcodeService().qrCodePictureUrl(ticket.getTicket());
+        return R.success(Map.of("ticket", ticket.getTicket(), "url", url, "stationId", stationId));
+    }
+
+    /**
+     * 查询故障记录列表
+     */
+    @SaCheckPermission("faultRecord.list")
+    @GetMapping("/faultRecords")
+    R<?> faultRecords(@RequestParam(required = false) String stationId,
+                       @RequestParam(required = false) String faultType,
+                       @RequestParam(required = false) Integer isRecovered) {
+        var query = faultRecordService.lambdaQuery();
+        if (stationId != null && !stationId.isEmpty()) {
+            query.eq(FaultRecord::getStationId, stationId);
+        }
+        if (faultType != null && !faultType.isEmpty()) {
+            query.eq(FaultRecord::getFaultType, faultType);
+        }
+        if (isRecovered != null) {
+            query.eq(FaultRecord::getIsRecovered, isRecovered);
+        }
+        var list = query.orderByDesc(FaultRecord::getFaultTime).list();
+        return R.success(list);
+    }
+}

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

@@ -0,0 +1,34 @@
+package com.kym.admin.jobs;
+
+import com.kym.service.FaultNotificationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 故障通知定时兜底任务
+ * 每5分钟扫描一次,补发遗漏的故障通知
+ *
+ * @author skyline
+ */
+@Component
+@Slf4j
+public class FaultNotificationJob {
+
+    private final FaultNotificationService faultNotificationService;
+
+    public FaultNotificationJob(FaultNotificationService faultNotificationService) {
+        this.faultNotificationService = faultNotificationService;
+    }
+
+    @Scheduled(cron = "0 */5 * * * ?")
+    public void reconcileFaultNotifications() {
+        log.debug("故障通知兜底扫描开始...");
+        try {
+            faultNotificationService.reconcileUnnotifiedFaults();
+        } catch (Exception e) {
+            log.error("故障通知兜底扫描异常", e);
+        }
+        log.debug("故障通知兜底扫描结束");
+    }
+}

+ 37 - 0
car-wash-common/src/main/java/com/kym/common/enums/FaultType.java

@@ -0,0 +1,37 @@
+package com.kym.common.enums;
+
+/**
+ * 设备故障类型枚举
+ *
+ * @author skyline
+ */
+public enum FaultType {
+    OFFLINE("offline", "设备离线"),
+    WATER_SHORTAGE("water_shortage", "缺水"),
+    FOAM_SHORTAGE("foam_shortage", "缺泡沫");
+
+    private final String code;
+    private final String desc;
+
+    FaultType(String code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getDesc() {
+        return desc;
+    }
+
+    public static FaultType fromCode(String code) {
+        for (FaultType type : values()) {
+            if (type.getCode().equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("未知的故障类型: " + code);
+    }
+}

+ 3 - 1
car-wash-common/src/main/java/com/kym/common/enums/MsgTemplateType.java

@@ -8,7 +8,9 @@ public enum MsgTemplateType {
     ORDER_COMPLETED("ORDER_COMPLETED"),      // 订单完成提醒
     ORDER_COMPLETED("ORDER_COMPLETED"),      // 订单完成提醒
     PARKING_COUPON("PARKING_COUPON"),      // 订单达标领取停车优惠券
     PARKING_COUPON("PARKING_COUPON"),      // 订单达标领取停车优惠券
     REFUND_APPLY("REFUND_APPLY"),       // 退款申请提交通知
     REFUND_APPLY("REFUND_APPLY"),       // 退款申请提交通知
-    REFUND_SUCCESS("REFUND_SUCCESS");       // 退款成功
+    REFUND_SUCCESS("REFUND_SUCCESS"),       // 退款成功
+    FAULT_ALERT("FAULT_ALERT"),             // 设备故障提醒
+    FAULT_RECOVER("FAULT_RECOVER");         // 设备故障恢复提醒
 
 
     private final String bizType;
     private final String bizType;
 
 

+ 73 - 0
car-wash-entity/src/main/java/com/kym/entity/FaultRecord.java

@@ -0,0 +1,73 @@
+package com.kym.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+/**
+ * 设备故障记录表
+ *
+ * @author skyline
+ */
+@Getter
+@Setter
+@TableName("t_fault_record")
+@Accessors(chain = true)
+public class FaultRecord extends BaseEntity<FaultRecord> {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final int IS_NOTIFIED_未通知 = 0;
+    public static final int IS_NOTIFIED_已通知 = 1;
+
+    public static final int IS_RECOVERED_未恢复 = 0;
+    public static final int IS_RECOVERED_已恢复 = 1;
+
+    /**
+     * 站点ID
+     */
+    private String stationId;
+
+    /**
+     * 设备名称
+     */
+    private String deviceName;
+
+    /**
+     * 故障类型:offline / water_shortage / foam_shortage
+     */
+    private String faultType;
+
+    /**
+     * 故障发生时间
+     */
+    private LocalDateTime faultTime;
+
+    /**
+     * 故障原因
+     */
+    private String faultReason;
+
+    /**
+     * 是否已通知:0-未通知,1-已通知
+     */
+    private Integer isNotified;
+
+    /**
+     * 通知发送时间
+     */
+    private LocalDateTime notifyTime;
+
+    /**
+     * 是否已恢复:0-未恢复,1-已恢复
+     */
+    private Integer isRecovered;
+
+    /**
+     * 恢复时间
+     */
+    private LocalDateTime recoverTime;
+}

+ 55 - 0
car-wash-entity/src/main/java/com/kym/entity/FaultSubscriber.java

@@ -0,0 +1,55 @@
+package com.kym.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+/**
+ * 故障通知订阅表
+ *
+ * @author skyline
+ */
+@Getter
+@Setter
+@TableName("t_fault_subscriber")
+@Accessors(chain = true)
+public class FaultSubscriber extends BaseEntity<FaultSubscriber> {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final int STATUS_已解绑 = 0;
+    public static final int STATUS_已订阅 = 1;
+
+    /**
+     * 微信公众号openid
+     */
+    private String openid;
+
+    /**
+     * 微信昵称(冗余显示)
+     */
+    private String nickname;
+
+    /**
+     * 站点ID
+     */
+    private String stationId;
+
+    /**
+     * 状态:0-已解绑,1-已订阅
+     */
+    private Integer status;
+
+    /**
+     * 绑定时间
+     */
+    private LocalDateTime subscribeTime;
+
+    /**
+     * 解绑时间
+     */
+    private LocalDateTime unbindTime;
+}

+ 1 - 0
car-wash-entity/src/main/java/com/kym/entity/common/RedisKeys.java

@@ -23,5 +23,6 @@ public interface RedisKeys {
 
 
     // =======================================洗车======================================
     // =======================================洗车======================================
 
 
+    String FAULT_NOTIFIED = "FAULT_NOTIFIED:";
 
 
 }
 }

+ 23 - 0
car-wash-entity/src/main/resources/sql/v3_notice_test_data.sql

@@ -0,0 +1,23 @@
+-- ============================================
+-- 公告测试数据 (执行前请确认 target 列已存在)
+-- ============================================
+
+-- 添加 target 列(如未添加)
+ALTER TABLE `t_system_notice`
+ADD COLUMN `target` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT '目标:client-客户端, merchant-商户端, all-全部' AFTER `status`;
+
+-- 字典条目(如未添加)
+INSERT IGNORE INTO `t_data_dict` (`code`, `name`, `value`, `weight`, `remark`, `color`) VALUES
+('notice_target', '客户端', 'client', 1, '公告目标', ''),
+('notice_target', '商户端', 'merchant', 2, '公告目标', ''),
+('notice_target', '全部', 'all', 3, '公告目标', '');
+
+-- 测试公告数据
+INSERT INTO `t_system_notice` (`id`, `title`, `content`, `admin_user_name`, `admin_user_id`, `start_time`, `end_time`, `status`, `target`, `create_time`, `update_time`) VALUES
+(1001, '停机维护通知', '尊敬的用户,本站将于6月1日凌晨2:00-6:00进行设备维护,届时将暂停服务,请您提前安排好洗车时间,给您带来的不便敬请谅解。', '系统管理员', 1, '2025-05-28 00:00:00', '2025-07-01 00:00:00', 1, 'client', NOW(), NOW()),
+(1002, '618洗车优惠活动', '618年中大促来袭!全场洗车8折优惠,充值满200送50,活动时间6月15日-6月25日,快来享受优惠吧!', '系统管理员', 1, '2025-05-28 00:00:00', '2025-07-01 00:00:00', 1, 'client', NOW(), NOW()),
+(1003, '新功能上线通知', '洗车小程序全新升级!新增预约洗车、积分兑换等功能,立即体验更便捷的洗车服务。', '系统管理员', 1, '2025-05-28 00:00:00', '2025-07-15 00:00:00', 1, 'all', NOW(), NOW()),
+(1004, '商户端结算规则调整', '各位商户请注意,从6月起结算周期调整为T+3,请及时关注账单变化。如有疑问请联系运营人员。', '系统管理员', 1, '2025-05-28 00:00:00', '2025-07-01 00:00:00', 1, 'merchant', NOW(), NOW()),
+(1005, '已过期的旧公告', '这是一条已过期的测试公告,不应该在客户端显示。', '系统管理员', 1, '2024-01-01 00:00:00', '2024-02-01 00:00:00', 1, 'client', NOW(), NOW()),
+(1006, '夏日清凉洗车节', '炎炎夏日,清凉洗车!7月1日-8月31日全场洗车立减5元,会员还可享双倍积分,快来体验吧。', '系统管理员', 1, '2025-05-28 00:00:00', '2025-09-01 00:00:00', 1, 'all', NOW(), NOW()),
+(1007, '五一劳动节放假通知', '五一期间洗车站点正常营业,部分站点营业时间调整为8:00-20:00,请留意站点公告。', '系统管理员', 1, '2025-04-20 00:00:00', '2025-05-10 00:00:00', 1, 'client', NOW(), NOW());

+ 13 - 0
car-wash-entity/src/main/resources/sql/v3_wash_station.sql

@@ -0,0 +1,13 @@
+-- ====================================================
+-- 站点信息表 V3 数据库变更脚本
+-- 新增运营主体、统一社会信用代码、场站联系人字段
+-- 发布日期:2026-05-28
+-- ====================================================
+
+-- ----------------------------
+-- 1. 站点信息表新增字段
+-- ----------------------------
+ALTER TABLE `t_wash_station`
+    ADD COLUMN `operation_entity` VARCHAR(200) DEFAULT NULL COMMENT '运营主体',
+    ADD COLUMN `credit_code` VARCHAR(50) DEFAULT NULL COMMENT '统一社会信用代码',
+    ADD COLUMN `contact_person` VARCHAR(50) DEFAULT NULL COMMENT '场站联系人';

+ 2 - 0
car-wash-entity/src/main/resources/sql/v4_pa_device.sql

@@ -0,0 +1,2 @@
+-- 设备表增加PA壶支持字段
+ALTER TABLE t_wash_device ADD COLUMN has_pa tinyint(1) DEFAULT 0 COMMENT '是否支持PA壶:0不支持,1支持';

+ 2 - 0
car-wash-entity/src/main/resources/sql/v5_wash_device_sequence.sql

@@ -0,0 +1,2 @@
+-- 设备表增加工位序号字段
+ALTER TABLE t_wash_device ADD COLUMN sequence int DEFAULT 0 COMMENT '工位序号,如1代表1号工位,2代表2号工位,每个站点内唯一';

+ 108 - 0
car-wash-entity/src/main/resources/sql/v6_fault_notification.sql

@@ -0,0 +1,108 @@
+-- ====================================================
+-- v6: 设备故障提醒系统
+-- 包含故障订阅表和故障记录表
+-- ====================================================
+
+-- ----------------------------
+-- 1. 故障通知订阅表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `t_fault_subscriber` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `company_id` BIGINT DEFAULT NULL COMMENT '公司ID(租户隔离)',
+    `openid` VARCHAR(64) NOT NULL COMMENT '微信公众号openid',
+    `nickname` VARCHAR(64) DEFAULT NULL COMMENT '微信昵称(冗余显示)',
+    `station_id` VARCHAR(32) NOT NULL COMMENT '站点ID',
+    `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-已解绑,1-已订阅',
+    `subscribe_time` DATETIME DEFAULT NULL COMMENT '绑定时间',
+    `unbind_time` DATETIME DEFAULT NULL COMMENT '解绑时间',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_openid_station` (`openid`, `station_id`),
+    KEY `idx_station_id` (`station_id`),
+    KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='故障通知订阅表';
+
+-- ----------------------------
+-- 2. 故障记录表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `t_fault_record` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `company_id` BIGINT DEFAULT NULL COMMENT '公司ID(租户隔离)',
+    `station_id` VARCHAR(32) NOT NULL COMMENT '站点ID',
+    `device_name` VARCHAR(64) NOT NULL COMMENT '设备名称',
+    `fault_type` VARCHAR(32) NOT NULL COMMENT '故障类型:offline-离线, water_shortage-缺水, foam_shortage-缺泡沫',
+    `fault_time` DATETIME NOT NULL COMMENT '故障发生时间',
+    `fault_reason` VARCHAR(255) DEFAULT NULL COMMENT '故障原因',
+    `is_notified` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已通知:0-未通知,1-已通知',
+    `notify_time` DATETIME DEFAULT NULL COMMENT '通知发送时间',
+    `is_recovered` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已恢复:0-未恢复,1-已恢复',
+    `recover_time` DATETIME DEFAULT NULL COMMENT '恢复时间',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_station_device` (`station_id`, `device_name`),
+    KEY `idx_fault_type` (`fault_type`),
+    KEY `idx_is_notified` (`is_notified`),
+    KEY `idx_is_recovered` (`is_recovered`),
+    KEY `idx_fault_time` (`fault_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备故障记录表';
+
+-- ----------------------------
+-- 3. 公众号模版消息配置(故障提醒)
+-- 注意:templateId 需要在微信公众平台后台申请后填入
+-- ----------------------------
+INSERT INTO `t_mp_msg_template` (`template_id`, `biz_type`, `title`, `primary_industry`, `deputy_industry`, `content`, `example`, `page_path`, `create_time`, `update_time`)
+VALUES (
+    '',  -- 待替换为实际申请的模版ID
+    'FAULT_ALERT',
+    '设备故障提醒',
+    'IT科技',
+    'IT软件与服务',
+    '{thing1.DATA}{thing2.DATA}{thing3.DATA}{time4.DATA}',
+    '站点:XX洗车站点\n设备:设备001\n故障类型:设备离线\n故障时间:2025-01-01 12:00:00',
+    '',
+    NOW(),
+    NOW()
+);
+
+-- ----------------------------
+-- 4. 公众号模版消息配置(故障恢复提醒)
+-- ----------------------------
+INSERT INTO `t_mp_msg_template` (`template_id`, `biz_type`, `title`, `primary_industry`, `deputy_industry`, `content`, `example`, `page_path`, `create_time`, `update_time`)
+VALUES (
+    '',  -- 待替换为实际申请的模版ID
+    'FAULT_RECOVER',
+    '设备故障恢复提醒',
+    'IT科技',
+    'IT软件与服务',
+    '{thing1.DATA}{thing2.DATA}{thing3.DATA}{time4.DATA}',
+    '站点:XX洗车站点\n设备:设备001\n故障类型:设备离线\n恢复时间:2025-01-01 14:00:00',
+    '',
+    NOW(),
+    NOW()
+);
+
+-- ----------------------------
+-- 5. 权限:故障订阅管理
+-- ----------------------------
+INSERT INTO `t_permission` (`id`, `company_id`, `name`, `value`, `pid`, `weight`) VALUES
+(NULL, NULL, '故障订阅管理', 'faultSubscriber', 0, 60),
+(NULL, NULL, '订阅列表', 'faultSubscriber.list', (SELECT id FROM (SELECT id FROM `t_permission` WHERE `value` = 'faultSubscriber') AS t), 1),
+(NULL, NULL, '解绑订阅', 'faultSubscriber.delete', (SELECT id FROM (SELECT id FROM `t_permission` WHERE `value` = 'faultSubscriber') AS t), 2),
+(NULL, NULL, '生成二维码', 'faultSubscriber.qrcode', (SELECT id FROM (SELECT id FROM `t_permission` WHERE `value` = 'faultSubscriber') AS t), 3),
+(NULL, NULL, '故障记录', 'faultRecord.list', (SELECT id FROM (SELECT id FROM `t_permission` WHERE `value` = 'faultSubscriber') AS t), 4);
+
+-- 将新权限追加到超级管理员角色(role_id=1)的 permissions 字段中(分隔符为 |)
+UPDATE `t_role` SET `permissions` = CONCAT(
+    IFNULL(`permissions`, ''),
+    '|faultSubscriber|faultSubscriber.list|faultSubscriber.delete|faultSubscriber.qrcode|faultRecord.list'
+) WHERE `id` = 1;
+
+-- ----------------------------
+-- 6. 数据字典:故障类型
+-- ----------------------------
+INSERT INTO `t_data_dict` (`code`, `name`, `value`, `weight`, `remark`, `color`) VALUES
+('FaultRecord.faultType', '设备离线', 'offline', 1, '故障类型', 'danger'),
+('FaultRecord.faultType', '缺水', 'water_shortage', 2, '故障类型', 'warning'),
+('FaultRecord.faultType', '缺泡沫', 'foam_shortage', 3, '故障类型', 'warning');

+ 9 - 0
car-wash-mapper/src/main/java/com/kym/mapper/FaultRecordMapper.java

@@ -0,0 +1,9 @@
+package com.kym.mapper;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.FaultRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FaultRecordMapper extends MPJBaseMapper<FaultRecord> {
+}

+ 9 - 0
car-wash-mapper/src/main/java/com/kym/mapper/FaultSubscriberMapper.java

@@ -0,0 +1,9 @@
+package com.kym.mapper;
+
+import com.github.yulichang.base.MPJBaseMapper;
+import com.kym.entity.FaultSubscriber;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FaultSubscriberMapper extends MPJBaseMapper<FaultSubscriber> {
+}

+ 38 - 0
car-wash-service/src/main/java/com/kym/service/FaultNotificationService.java

@@ -0,0 +1,38 @@
+package com.kym.service;
+
+/**
+ * 故障通知核心服务 —— 检测故障、触发通知、防重复、恢复通知
+ *
+ * @author skyline
+ */
+public interface FaultNotificationService {
+
+    /**
+     * 处理设备离线故障
+     */
+    void handleOfflineFault(String stationId, String deviceName);
+
+    /**
+     * 处理设备上线恢复
+     */
+    void handleOnlineRecovery(String stationId, String deviceName);
+
+    /**
+     * 处理缺水/缺泡沫故障
+     *
+     * @param stationId  站点ID
+     * @param deviceName 设备名称
+     * @param faultType  故障类型 (water_shortage / foam_shortage)
+     */
+    void handleResourceShortage(String stationId, String deviceName, String faultType);
+
+    /**
+     * 处理水/泡沫恢复
+     */
+    void handleResourceRecovery(String stationId, String deviceName, String faultType);
+
+    /**
+     * 定时兜底:扫描未通知的故障记录并发送通知
+     */
+    void reconcileUnnotifiedFaults();
+}

+ 27 - 0
car-wash-service/src/main/java/com/kym/service/FaultRecordService.java

@@ -0,0 +1,27 @@
+package com.kym.service;
+
+import com.github.yulichang.base.MPJBaseService;
+import com.kym.entity.FaultRecord;
+
+/**
+ * 设备故障记录服务
+ *
+ * @author skyline
+ */
+public interface FaultRecordService extends MPJBaseService<FaultRecord> {
+
+    /**
+     * 查询指定设备指定故障类型的未恢复记录
+     */
+    FaultRecord getUnrecovered(String stationId, String deviceName, String faultType);
+
+    /**
+     * 标记故障已恢复
+     */
+    void markRecovered(Long id);
+
+    /**
+     * 标记故障已通知
+     */
+    void markNotified(Long id);
+}

+ 40 - 0
car-wash-service/src/main/java/com/kym/service/FaultSubscriberService.java

@@ -0,0 +1,40 @@
+package com.kym.service;
+
+import com.github.yulichang.base.MPJBaseService;
+import com.kym.entity.FaultSubscriber;
+
+import java.util.List;
+
+/**
+ * 故障通知订阅服务
+ *
+ * @author skyline
+ */
+public interface FaultSubscriberService extends MPJBaseService<FaultSubscriber> {
+
+    /**
+     * 绑定订阅(扫码绑定)
+     *
+     * @param openid    公众号openid
+     * @param stationId 站点ID
+     */
+    void subscribe(String openid, String stationId);
+
+    /**
+     * 解绑订阅(再次扫码解绑)
+     *
+     * @param openid    公众号openid
+     * @param stationId 站点ID
+     */
+    void unsubscribe(String openid, String stationId);
+
+    /**
+     * 获取站点的所有已订阅的openid列表
+     */
+    List<String> getSubscribedOpenids(String stationId);
+
+    /**
+     * 切换订阅状态(绑定↔解绑),返回切换后的状态描述
+     */
+    String toggleSubscription(String openid, String stationId);
+}

+ 56 - 9
car-wash-service/src/main/java/com/kym/service/awoara/event/handle/DeviceStateEventHandler.java

@@ -1,42 +1,48 @@
 package com.kym.service.awoara.event.handle;
 package com.kym.service.awoara.event.handle;
 
 
+import com.kym.common.enums.FaultType;
 import com.kym.entity.WashDevice;
 import com.kym.entity.WashDevice;
 import com.kym.entity.awoara.DeviceStateObject;
 import com.kym.entity.awoara.DeviceStateObject;
 import com.kym.entity.awoara.MessageBody;
 import com.kym.entity.awoara.MessageBody;
+import com.kym.service.FaultNotificationService;
 import com.kym.service.WashDeviceService;
 import com.kym.service.WashDeviceService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
 /**
 /**
- * 设备状态更新事件事件处理
+ * 设备状态更新事件处理
  *
  *
  * @author skyline
  * @author skyline
  */
  */
 @Slf4j
 @Slf4j
-@Component
 public class DeviceStateEventHandler implements AwoaraEventHandler<DeviceStateObject> {
 public class DeviceStateEventHandler implements AwoaraEventHandler<DeviceStateObject> {
 
 
     private final WashDeviceService washDeviceService;
     private final WashDeviceService washDeviceService;
+    private final FaultNotificationService faultNotificationService;
 
 
-    public DeviceStateEventHandler(WashDeviceService washDeviceService) {
+    public DeviceStateEventHandler(WashDeviceService washDeviceService,
+                                   FaultNotificationService faultNotificationService) {
         this.washDeviceService = washDeviceService;
         this.washDeviceService = washDeviceService;
+        this.faultNotificationService = faultNotificationService;
     }
     }
 
 
     @Override
     @Override
     @Transactional
     @Transactional
     public void handle(MessageBody<DeviceStateObject> message) {
     public void handle(MessageBody<DeviceStateObject> message) {
-        log.info(message.toString());
-        log.info("DeviceStateEventHandler");
+        log.debug("DeviceStateEventHandler: {}", message);
 
 
-        // 获取设备信息 从topic中获取
-        log.debug("topic:{}", message.getTopic());
         var topic = message.getTopic().split("/");
         var topic = message.getTopic().split("/");
         var productKey = topic[1];
         var productKey = topic[1];
         var deviceName = topic[2];
         var deviceName = topic[2];
         var deviceState = message.getPayload().getData().getDevice_state();
         var deviceState = message.getPayload().getData().getDevice_state();
 
 
-        // 状态更新逻辑
+        // 读取更新前的状态用于对比
+        var oldDevice = washDeviceService.lambdaQuery()
+                .eq(WashDevice::getProductKey, productKey)
+                .eq(WashDevice::getDeviceName, deviceName)
+                .one();
+
+        // 状态更新
         washDeviceService.lambdaUpdate()
         washDeviceService.lambdaUpdate()
                 .set(WashDevice::getUptimeMs, deviceState.getUptime_ms())
                 .set(WashDevice::getUptimeMs, deviceState.getUptime_ms())
                 .set(WashDevice::getState, deviceState.getState())
                 .set(WashDevice::getState, deviceState.getState())
@@ -48,5 +54,46 @@ public class DeviceStateEventHandler implements AwoaraEventHandler<DeviceStateOb
                 .eq(WashDevice::getProductKey, productKey)
                 .eq(WashDevice::getProductKey, productKey)
                 .eq(WashDevice::getDeviceName, deviceName)
                 .eq(WashDevice::getDeviceName, deviceName)
                 .update();
                 .update();
+
+        // 检测水和泡沫变化
+        if (oldDevice != null) {
+            detectWaterChange(oldDevice, deviceState.getHas_water(), deviceName);
+            detectFoamChange(oldDevice, deviceState.getHas_foam(), deviceName);
+        }
+    }
+
+    private void detectWaterChange(WashDevice oldDevice, String newHasWaterVal, String deviceName) {
+        var stationId = oldDevice.getStationId();
+        int newVal = parseResourceValue(newHasWaterVal);
+        boolean oldHasWater = oldDevice.getHasWater() != null && oldDevice.getHasWater();
+        boolean newHasWater = newVal == 1;
+
+        if (oldHasWater && !newHasWater) {
+            log.info("检测到缺水: stationId={}, deviceName={}", stationId, deviceName);
+            faultNotificationService.handleResourceShortage(stationId, deviceName, FaultType.WATER_SHORTAGE.getCode());
+        } else if (!oldHasWater && newHasWater) {
+            log.info("检测到水恢复: stationId={}, deviceName={}", stationId, deviceName);
+            faultNotificationService.handleResourceRecovery(stationId, deviceName, FaultType.WATER_SHORTAGE.getCode());
+        }
+    }
+
+    private void detectFoamChange(WashDevice oldDevice, String newHasFoamVal, String deviceName) {
+        var stationId = oldDevice.getStationId();
+        int newVal = parseResourceValue(newHasFoamVal);
+        boolean oldHasFoam = oldDevice.getHasFoam() != null && oldDevice.getHasFoam();
+        boolean newHasFoam = newVal == 1;
+
+        if (oldHasFoam && !newHasFoam) {
+            log.info("检测到缺泡沫: stationId={}, deviceName={}", stationId, deviceName);
+            faultNotificationService.handleResourceShortage(stationId, deviceName, FaultType.FOAM_SHORTAGE.getCode());
+        } else if (!oldHasFoam && newHasFoam) {
+            log.info("检测到泡沫恢复: stationId={}, deviceName={}", stationId, deviceName);
+            faultNotificationService.handleResourceRecovery(stationId, deviceName, FaultType.FOAM_SHORTAGE.getCode());
+        }
+    }
+
+    private int parseResourceValue(String val) {
+        if (val == null) return -1;
+        return Integer.parseInt(val);
     }
     }
 }
 }

+ 9 - 2
car-wash-service/src/main/java/com/kym/service/awoara/event/handle/DeviceStatusEventHandler.java

@@ -5,11 +5,11 @@ import com.kym.entity.WashDevice;
 import com.kym.entity.awoara.DeviceStatusObject;
 import com.kym.entity.awoara.DeviceStatusObject;
 import com.kym.entity.awoara.MessageBody;
 import com.kym.entity.awoara.MessageBody;
 import com.kym.entity.common.RedisKeys;
 import com.kym.entity.common.RedisKeys;
+import com.kym.service.FaultNotificationService;
 import com.kym.service.MonitorLogService;
 import com.kym.service.MonitorLogService;
 import com.kym.service.WashDeviceService;
 import com.kym.service.WashDeviceService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
 import java.time.Duration;
 import java.time.Duration;
@@ -26,13 +26,16 @@ public class DeviceStatusEventHandler implements AwoaraEventHandler<DeviceStatus
     private final WashDeviceService washDeviceService;
     private final WashDeviceService washDeviceService;
     private final MonitorLogService monitorLogService;
     private final MonitorLogService monitorLogService;
     private final StringRedisTemplate stringRedisTemplate;
     private final StringRedisTemplate stringRedisTemplate;
+    private final FaultNotificationService faultNotificationService;
 
 
     public DeviceStatusEventHandler(WashDeviceService washDeviceService,
     public DeviceStatusEventHandler(WashDeviceService washDeviceService,
                                     MonitorLogService monitorLogService,
                                     MonitorLogService monitorLogService,
-                                    StringRedisTemplate stringRedisTemplate) {
+                                    StringRedisTemplate stringRedisTemplate,
+                                    FaultNotificationService faultNotificationService) {
         this.washDeviceService = washDeviceService;
         this.washDeviceService = washDeviceService;
         this.monitorLogService = monitorLogService;
         this.monitorLogService = monitorLogService;
         this.stringRedisTemplate = stringRedisTemplate;
         this.stringRedisTemplate = stringRedisTemplate;
+        this.faultNotificationService = faultNotificationService;
     }
     }
 
 
     @Override
     @Override
@@ -81,6 +84,8 @@ public class DeviceStatusEventHandler implements AwoaraEventHandler<DeviceStatus
                 .setIsNotice(MonitorLog.IS_RECOVER_未恢复)
                 .setIsNotice(MonitorLog.IS_RECOVER_未恢复)
                 .setIsRecover(MonitorLog.IS_RECOVER_未恢复);
                 .setIsRecover(MonitorLog.IS_RECOVER_未恢复);
         monitorLogService.save(logEntry);
         monitorLogService.save(logEntry);
+
+        faultNotificationService.handleOfflineFault(device.getStationId(), deviceName);
     }
     }
 
 
     private void handleOnline(WashDevice device, String deviceName, String offlineKey) {
     private void handleOnline(WashDevice device, String deviceName, String offlineKey) {
@@ -102,5 +107,7 @@ public class DeviceStatusEventHandler implements AwoaraEventHandler<DeviceStatus
                     .eq(MonitorLog::getId, unrecoveredLog.getId())
                     .eq(MonitorLog::getId, unrecoveredLog.getId())
                     .update();
                     .update();
         }
         }
+
+        faultNotificationService.handleOnlineRecovery(device.getStationId(), deviceName);
     }
     }
 }
 }

+ 6 - 2
car-wash-service/src/main/java/com/kym/service/awoara/factory/AwoaraEventHandlerFactory.java

@@ -2,6 +2,7 @@ package com.kym.service.awoara.factory;
 
 
 import com.kym.common.exception.BusinessException;
 import com.kym.common.exception.BusinessException;
 import com.kym.entity.awoara.Event;
 import com.kym.entity.awoara.Event;
+import com.kym.service.FaultNotificationService;
 import com.kym.service.MonitorLogService;
 import com.kym.service.MonitorLogService;
 import com.kym.service.OrderSettlementService;
 import com.kym.service.OrderSettlementService;
 import com.kym.service.WashDeviceService;
 import com.kym.service.WashDeviceService;
@@ -28,6 +29,7 @@ public class AwoaraEventHandlerFactory {
     private static MonitorLogService monitorLogService;
     private static MonitorLogService monitorLogService;
     private static StringRedisTemplate stringRedisTemplate;
     private static StringRedisTemplate stringRedisTemplate;
     private static AwoaraService awoaraService;
     private static AwoaraService awoaraService;
+    private static FaultNotificationService faultNotificationService;
 
 
 
 
     public AwoaraEventHandlerFactory(WashDeviceService washDeviceService, WashOrderService washOrderService,
     public AwoaraEventHandlerFactory(WashDeviceService washDeviceService, WashOrderService washOrderService,
@@ -35,6 +37,7 @@ public class AwoaraEventHandlerFactory {
                                      MonitorLogService monitorLogService,
                                      MonitorLogService monitorLogService,
                                      StringRedisTemplate stringRedisTemplate,
                                      StringRedisTemplate stringRedisTemplate,
                                      AwoaraService awoaraService,
                                      AwoaraService awoaraService,
+                                     FaultNotificationService faultNotificationService,
     @Value("${kym.domain}") String domain) {
     @Value("${kym.domain}") String domain) {
         DOMAIN = domain;
         DOMAIN = domain;
         AwoaraEventHandlerFactory.washDeviceService = washDeviceService;
         AwoaraEventHandlerFactory.washDeviceService = washDeviceService;
@@ -43,6 +46,7 @@ public class AwoaraEventHandlerFactory {
         AwoaraEventHandlerFactory.monitorLogService = monitorLogService;
         AwoaraEventHandlerFactory.monitorLogService = monitorLogService;
         AwoaraEventHandlerFactory.stringRedisTemplate = stringRedisTemplate;
         AwoaraEventHandlerFactory.stringRedisTemplate = stringRedisTemplate;
         AwoaraEventHandlerFactory.awoaraService = awoaraService;
         AwoaraEventHandlerFactory.awoaraService = awoaraService;
+        AwoaraEventHandlerFactory.faultNotificationService = faultNotificationService;
     }
     }
 
 
     public static AwoaraEventHandler getEventHandler(String eventName) {
     public static AwoaraEventHandler getEventHandler(String eventName) {
@@ -50,14 +54,14 @@ public class AwoaraEventHandlerFactory {
             Event event = Event.valueOf(eventName);
             Event event = Event.valueOf(eventName);
             return switch (event) {
             return switch (event) {
                 case boot -> new BootEventHandler(washDeviceService, washOrderService, awoaraService, orderSettlementService);
                 case boot -> new BootEventHandler(washDeviceService, washOrderService, awoaraService, orderSettlementService);
-                case device_state -> new DeviceStateEventHandler(washDeviceService);
+                case device_state -> new DeviceStateEventHandler(washDeviceService, faultNotificationService);
                 case order_create -> new OrderCreateEventHandler(washOrderService);
                 case order_create -> new OrderCreateEventHandler(washOrderService);
                 case order_update -> new OrderUpdateEventHandler(washOrderService);
                 case order_update -> new OrderUpdateEventHandler(washOrderService);
                 case order_close ->
                 case order_close ->
                         new OrderCloseEventHandler(washOrderService, orderSettlementService);
                         new OrderCloseEventHandler(washOrderService, orderSettlementService);
                 case user_login -> new UserLoginEventHandler();
                 case user_login -> new UserLoginEventHandler();
                 case card_event -> new CardEventHandler();
                 case card_event -> new CardEventHandler();
-                case device_status -> new DeviceStatusEventHandler(washDeviceService, monitorLogService, stringRedisTemplate);
+                case device_status -> new DeviceStatusEventHandler(washDeviceService, monitorLogService, stringRedisTemplate, faultNotificationService);
             };
             };
         } catch (IllegalArgumentException e) {
         } catch (IllegalArgumentException e) {
             throw new BusinessException("无效的事件名称: " + eventName);
             throw new BusinessException("无效的事件名称: " + eventName);

+ 220 - 0
car-wash-service/src/main/java/com/kym/service/impl/FaultNotificationServiceImpl.java

@@ -0,0 +1,220 @@
+package com.kym.service.impl;
+
+import com.kym.common.enums.FaultType;
+import com.kym.common.enums.MsgTemplateType;
+import com.kym.common.utils.CommUtil;
+import com.kym.common.utils.wx.WxPbUtil;
+import com.kym.entity.FaultRecord;
+import com.kym.entity.MpMsgTemplate;
+import com.kym.entity.common.RedisKeys;
+import com.kym.service.FaultNotificationService;
+import com.kym.service.FaultRecordService;
+import com.kym.service.FaultSubscriberService;
+import com.kym.service.MpMsgTemplateService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+
+/**
+ * 故障通知核心服务实现
+ *
+ * @author skyline
+ */
+@Slf4j
+@Service
+public class FaultNotificationServiceImpl implements FaultNotificationService {
+
+    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final Duration ANTI_DUP_TTL = Duration.ofHours(2);
+
+    private final FaultRecordService faultRecordService;
+    private final FaultSubscriberService faultSubscriberService;
+    private final MpMsgTemplateService mpMsgTemplateService;
+    private final StringRedisTemplate stringRedisTemplate;
+
+    public FaultNotificationServiceImpl(FaultRecordService faultRecordService,
+                                         FaultSubscriberService faultSubscriberService,
+                                         MpMsgTemplateService mpMsgTemplateService,
+                                         StringRedisTemplate stringRedisTemplate) {
+        this.faultRecordService = faultRecordService;
+        this.faultSubscriberService = faultSubscriberService;
+        this.mpMsgTemplateService = mpMsgTemplateService;
+        this.stringRedisTemplate = stringRedisTemplate;
+    }
+
+    @Override
+    @Async
+    public void handleOfflineFault(String stationId, String deviceName) {
+        handleFault(stationId, deviceName, FaultType.OFFLINE, null);
+    }
+
+    @Override
+    @Async
+    public void handleOnlineRecovery(String stationId, String deviceName) {
+        handleRecovery(stationId, deviceName, FaultType.OFFLINE);
+    }
+
+    @Override
+    @Async
+    public void handleResourceShortage(String stationId, String deviceName, String faultType) {
+        FaultType type = FaultType.fromCode(faultType);
+        handleFault(stationId, deviceName, type, null);
+    }
+
+    @Override
+    @Async
+    public void handleResourceRecovery(String stationId, String deviceName, String faultType) {
+        FaultType type = FaultType.fromCode(faultType);
+        handleRecovery(stationId, deviceName, type);
+    }
+
+    @Override
+    public void reconcileUnnotifiedFaults() {
+        var unnotifiedList = faultRecordService.lambdaQuery()
+                .eq(FaultRecord::getIsNotified, FaultRecord.IS_NOTIFIED_未通知)
+                .eq(FaultRecord::getIsRecovered, FaultRecord.IS_RECOVERED_未恢复)
+                .list();
+
+        for (var record : unnotifiedList) {
+            try {
+                var redisKey = buildAntiDupKey(record.getFaultType(), record.getDeviceName(), record.getStationId());
+                if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(redisKey))) {
+                    faultRecordService.markNotified(record.getId());
+                } else {
+                    doNotify(record);
+                }
+            } catch (Exception e) {
+                log.error("兜底通知发送失败: recordId={}", record.getId(), e);
+            }
+        }
+    }
+
+    // ========== private ==========
+
+    private void handleFault(String stationId, String deviceName, FaultType faultType, String faultReason) {
+        var existing = faultRecordService.getUnrecovered(stationId, deviceName, faultType.getCode());
+        if (existing != null) {
+            log.debug("故障记录已存在,跳过: stationId={}, deviceName={}, faultType={}", stationId, deviceName, faultType.getCode());
+            return;
+        }
+
+        var record = new FaultRecord()
+                .setStationId(stationId)
+                .setDeviceName(deviceName)
+                .setFaultType(faultType.getCode())
+                .setFaultTime(LocalDateTime.now())
+                .setFaultReason(faultReason)
+                .setIsNotified(FaultRecord.IS_NOTIFIED_未通知)
+                .setIsRecovered(FaultRecord.IS_RECOVERED_未恢复);
+        faultRecordService.save(record);
+
+        var redisKey = buildAntiDupKey(faultType.getCode(), deviceName, stationId);
+        if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(redisKey))) {
+            log.debug("防重复key存在,跳过通知: {}", redisKey);
+            return;
+        }
+
+        doNotify(record);
+    }
+
+    private void handleRecovery(String stationId, String deviceName, FaultType faultType) {
+        var record = faultRecordService.getUnrecovered(stationId, deviceName, faultType.getCode());
+        if (record == null) {
+            log.debug("未找到未恢复的故障记录: stationId={}, deviceName={}, faultType={}", stationId, deviceName, faultType.getCode());
+            return;
+        }
+
+        faultRecordService.markRecovered(record.getId());
+        stringRedisTemplate.delete(buildAntiDupKey(faultType.getCode(), deviceName, stationId));
+        sendRecoveryNotification(record);
+    }
+
+    private void doNotify(FaultRecord record) {
+        var subscribers = faultSubscriberService.getSubscribedOpenids(record.getStationId());
+        if (subscribers.isEmpty()) {
+            log.debug("站点 {} 无故障通知订阅者,跳过通知", record.getStationId());
+            return;
+        }
+
+        var template = getMpMsgTemplate(MsgTemplateType.FAULT_ALERT);
+        if (template == null) {
+            log.warn("未找到故障提醒模版消息配置");
+            return;
+        }
+
+        var params = buildFaultAlertParams(record);
+        for (var openid : subscribers) {
+            try {
+                WxPbUtil.sendPublicTemplateMessage(openid, template.getTemplateId(), params, "", "");
+            } catch (Exception e) {
+                log.error("发送故障提醒失败: openid={}, recordId={}", openid, record.getId(), e);
+            }
+        }
+
+        faultRecordService.markNotified(record.getId());
+        stringRedisTemplate.opsForValue().set(
+                buildAntiDupKey(record.getFaultType(), record.getDeviceName(), record.getStationId()),
+                "1", ANTI_DUP_TTL);
+    }
+
+    private void sendRecoveryNotification(FaultRecord record) {
+        var subscribers = faultSubscriberService.getSubscribedOpenids(record.getStationId());
+        if (subscribers.isEmpty()) {
+            return;
+        }
+
+        var template = getMpMsgTemplate(MsgTemplateType.FAULT_RECOVER);
+        if (template == null) {
+            log.debug("未找到故障恢复提醒模版消息配置,跳过恢复通知");
+            return;
+        }
+
+        var params = buildFaultRecoverParams(record);
+        for (var openid : subscribers) {
+            try {
+                WxPbUtil.sendPublicTemplateMessage(openid, template.getTemplateId(), params, "", "");
+            } catch (Exception e) {
+                log.error("发送故障恢复提醒失败: openid={}, recordId={}", openid, record.getId(), e);
+            }
+        }
+    }
+
+    private Map<String, String> buildFaultAlertParams(FaultRecord record) {
+        return Map.of(
+                "thing1", getStationName(record.getStationId()),
+                "thing2", record.getDeviceName(),
+                "thing3", FaultType.fromCode(record.getFaultType()).getDesc(),
+                "time4", record.getFaultTime().format(FORMATTER)
+        );
+    }
+
+    private Map<String, String> buildFaultRecoverParams(FaultRecord record) {
+        return Map.of(
+                "thing1", getStationName(record.getStationId()),
+                "thing2", record.getDeviceName(),
+                "thing3", FaultType.fromCode(record.getFaultType()).getDesc(),
+                "time4", LocalDateTime.now().format(FORMATTER)
+        );
+    }
+
+    private MpMsgTemplate getMpMsgTemplate(MsgTemplateType type) {
+        return mpMsgTemplateService.lambdaQuery()
+                .eq(MpMsgTemplate::getBizType, type.getBizType())
+                .one();
+    }
+
+    private String buildAntiDupKey(String faultType, String deviceName, String stationId) {
+        return RedisKeys.FAULT_NOTIFIED + faultType + ":" + deviceName + ":" + stationId;
+    }
+
+    private String getStationName(String stationId) {
+        var name = com.kym.service.cache.KymCache.INSTANCE.getStationNameById(stationId);
+        return CommUtil.isNotEmptyAndNull(name) ? name : stationId;
+    }
+}

+ 43 - 0
car-wash-service/src/main/java/com/kym/service/impl/FaultRecordServiceImpl.java

@@ -0,0 +1,43 @@
+package com.kym.service.impl;
+
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.kym.entity.FaultRecord;
+import com.kym.mapper.FaultRecordMapper;
+import com.kym.service.FaultRecordService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+@Service
+public class FaultRecordServiceImpl extends MPJBaseServiceImpl<FaultRecordMapper, FaultRecord> implements FaultRecordService {
+
+    @Override
+    public FaultRecord getUnrecovered(String stationId, String deviceName, String faultType) {
+        return lambdaQuery()
+                .eq(FaultRecord::getStationId, stationId)
+                .eq(FaultRecord::getDeviceName, deviceName)
+                .eq(FaultRecord::getFaultType, faultType)
+                .eq(FaultRecord::getIsRecovered, FaultRecord.IS_RECOVERED_未恢复)
+                .orderByDesc(FaultRecord::getFaultTime)
+                .last("limit 1")
+                .one();
+    }
+
+    @Override
+    public void markRecovered(Long id) {
+        lambdaUpdate()
+                .set(FaultRecord::getIsRecovered, FaultRecord.IS_RECOVERED_已恢复)
+                .set(FaultRecord::getRecoverTime, LocalDateTime.now())
+                .eq(FaultRecord::getId, id)
+                .update();
+    }
+
+    @Override
+    public void markNotified(Long id) {
+        lambdaUpdate()
+                .set(FaultRecord::getIsNotified, FaultRecord.IS_NOTIFIED_已通知)
+                .set(FaultRecord::getNotifyTime, LocalDateTime.now())
+                .eq(FaultRecord::getId, id)
+                .update();
+    }
+}

+ 78 - 0
car-wash-service/src/main/java/com/kym/service/impl/FaultSubscriberServiceImpl.java

@@ -0,0 +1,78 @@
+package com.kym.service.impl;
+
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.kym.entity.FaultSubscriber;
+import com.kym.mapper.FaultSubscriberMapper;
+import com.kym.service.FaultSubscriberService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+public class FaultSubscriberServiceImpl extends MPJBaseServiceImpl<FaultSubscriberMapper, FaultSubscriber> implements FaultSubscriberService {
+
+    @Override
+    @Transactional
+    public void subscribe(String openid, String stationId) {
+        var existing = lambdaQuery()
+                .eq(FaultSubscriber::getOpenid, openid)
+                .eq(FaultSubscriber::getStationId, stationId)
+                .one();
+
+        if (existing != null) {
+            existing.setStatus(FaultSubscriber.STATUS_已订阅);
+            existing.setSubscribeTime(LocalDateTime.now());
+            existing.setUnbindTime(null);
+            updateById(existing);
+        } else {
+            var subscriber = new FaultSubscriber()
+                    .setOpenid(openid)
+                    .setStationId(stationId)
+                    .setStatus(FaultSubscriber.STATUS_已订阅)
+                    .setSubscribeTime(LocalDateTime.now());
+            save(subscriber);
+        }
+    }
+
+    @Override
+    @Transactional
+    public void unsubscribe(String openid, String stationId) {
+        lambdaUpdate()
+                .set(FaultSubscriber::getStatus, FaultSubscriber.STATUS_已解绑)
+                .set(FaultSubscriber::getUnbindTime, LocalDateTime.now())
+                .eq(FaultSubscriber::getOpenid, openid)
+                .eq(FaultSubscriber::getStationId, stationId)
+                .update();
+    }
+
+    @Override
+    public List<String> getSubscribedOpenids(String stationId) {
+        return lambdaQuery()
+                .select(FaultSubscriber::getOpenid)
+                .eq(FaultSubscriber::getStationId, stationId)
+                .eq(FaultSubscriber::getStatus, FaultSubscriber.STATUS_已订阅)
+                .list()
+                .stream()
+                .map(FaultSubscriber::getOpenid)
+                .toList();
+    }
+
+    @Override
+    @Transactional
+    public String toggleSubscription(String openid, String stationId) {
+        var existing = lambdaQuery()
+                .eq(FaultSubscriber::getOpenid, openid)
+                .eq(FaultSubscriber::getStationId, stationId)
+                .one();
+
+        if (existing != null && existing.getStatus() == FaultSubscriber.STATUS_已订阅) {
+            unsubscribe(openid, stationId);
+            return "解绑成功";
+        } else {
+            subscribe(openid, stationId);
+            return "绑定成功";
+        }
+    }
+}

+ 51 - 1
car-wash-service/src/main/java/com/kym/service/wechat/impl/WeixinMPServiceImpl.java

@@ -7,6 +7,7 @@ import cn.hutool.core.util.XmlUtil;
 import com.kym.common.utils.CommUtil;
 import com.kym.common.utils.CommUtil;
 import com.kym.entity.MpMsgTemplate;
 import com.kym.entity.MpMsgTemplate;
 import com.kym.entity.MpRelation;
 import com.kym.entity.MpRelation;
+import com.kym.service.FaultSubscriberService;
 import com.kym.service.MpMsgTemplateService;
 import com.kym.service.MpMsgTemplateService;
 import com.kym.service.impl.MpRelationServiceImpl;
 import com.kym.service.impl.MpRelationServiceImpl;
 import com.kym.service.wechat.WeixinMPService;
 import com.kym.service.wechat.WeixinMPService;
@@ -38,6 +39,7 @@ public class WeixinMPServiceImpl implements WeixinMPService {
     private final MpMsgTemplateService mpMsgTemplateService;
     private final MpMsgTemplateService mpMsgTemplateService;
     private final MpRelationServiceImpl mpRelationService;
     private final MpRelationServiceImpl mpRelationService;
     private final WxMpService wxMpService;
     private final WxMpService wxMpService;
+    private final FaultSubscriberService faultSubscriberService;
 
 
     /**
     /**
      * 小程序appid
      * 小程序appid
@@ -45,10 +47,12 @@ public class WeixinMPServiceImpl implements WeixinMPService {
     @Value("${wechat.miniapp.appid}")
     @Value("${wechat.miniapp.appid}")
     public String appid;
     public String appid;
 
 
-    public WeixinMPServiceImpl(MpMsgTemplateService mpMsgTemplateService, MpRelationServiceImpl mpRelationService, WxMpService wxMpService) {
+    public WeixinMPServiceImpl(MpMsgTemplateService mpMsgTemplateService, MpRelationServiceImpl mpRelationService,
+                               WxMpService wxMpService, FaultSubscriberService faultSubscriberService) {
         this.mpMsgTemplateService = mpMsgTemplateService;
         this.mpMsgTemplateService = mpMsgTemplateService;
         this.mpRelationService = mpRelationService;
         this.mpRelationService = mpRelationService;
         this.wxMpService = wxMpService;
         this.wxMpService = wxMpService;
+        this.faultSubscriberService = faultSubscriberService;
     }
     }
 
 
     /**
     /**
@@ -80,6 +84,8 @@ public class WeixinMPServiceImpl implements WeixinMPService {
                             log.info("收到微信公众号关注通知: {}", mpOpenid);
                             log.info("收到微信公众号关注通知: {}", mpOpenid);
                             // 通过openid获取用户信息中的unionid,关联小程序用户
                             // 通过openid获取用户信息中的unionid,关联小程序用户
                             mpRelationService.bindMpUser(mpOpenid);
                             mpRelationService.bindMpUser(mpOpenid);
+                            // 处理带场景值的关注(扫码绑定故障通知)
+                            handleSceneBinding(mpOpenid, ret, true);
                             break;
                             break;
                         case "unsubscribe":
                         case "unsubscribe":
                             // 取消关注公众号
                             // 取消关注公众号
@@ -90,6 +96,8 @@ public class WeixinMPServiceImpl implements WeixinMPService {
                         case "scan":
                         case "scan":
                             // 扫描二维码
                             // 扫描二维码
                             log.info("收到微信公众号扫描二维码通知: {}", mpOpenid);
                             log.info("收到微信公众号扫描二维码通知: {}", mpOpenid);
+                            // 处理带场景值的扫码(扫码绑定/解绑故障通知)
+                            handleSceneBinding(mpOpenid, ret, false);
                             break;
                             break;
                         case "location":
                         case "location":
                             // 上报地理位置
                             // 上报地理位置
@@ -146,6 +154,48 @@ public class WeixinMPServiceImpl implements WeixinMPService {
         }
         }
     }
     }
 
 
+    /**
+     * 处理扫码绑定/解绑故障通知
+     * scene_str 格式: stationId(直接使用站点ID作为场景值)
+     *
+     * @param mpOpenid  公众号openid
+     * @param eventData 微信推送的XML解析后的Map
+     * @param isSubscribe 是否为关注事件(关注事件的EventKey前缀为 qrscene_)
+     */
+    private void handleSceneBinding(String mpOpenid, Map<String, Object> eventData, boolean isSubscribe) {
+        try {
+            var eventKey = eventData.get("EventKey");
+            if (eventKey == null) {
+                return;
+            }
+            var eventKeyStr = eventKey.toString();
+            if (CommUtil.isEmptyOrNull(eventKeyStr)) {
+                return;
+            }
+
+            // 关注事件: qrscene_<scene_str>,扫码事件: <scene_str>
+            String stationId;
+            if (isSubscribe && eventKeyStr.startsWith("qrscene_")) {
+                stationId = eventKeyStr.substring("qrscene_".length());
+            } else {
+                stationId = eventKeyStr;
+            }
+
+            if (CommUtil.isEmptyOrNull(stationId)) {
+                return;
+            }
+
+            var result = faultSubscriberService.toggleSubscription(mpOpenid, stationId);
+            log.info("故障通知订阅操作: openid={}, stationId={}, result={}", mpOpenid, stationId, result);
+
+            // 将操作结果放入返回的XML中,微信会将其作为文本消息推送给用户
+            eventData.put("MsgType", "text");
+            eventData.put("Content", result + "!\n站点:" + stationId + "\n" + ("绑定成功".equals(result) ? "您将收到该站点的设备故障提醒。" : "您将不再收到该站点的设备故障提醒。"));
+        } catch (Exception e) {
+            log.error("处理场景绑定异常: mpOpenid={}", mpOpenid, e);
+        }
+    }
+
     /**
     /**
      * 获取模板消息的key
      * 获取模板消息的key
      *
      *