Quellcode durchsuchen

页面优化,设备同步优化

skyline vor 3 Wochen
Ursprung
Commit
e09c347dcb

+ 6 - 6
haha-admin-web/src/views/order/index.vue

@@ -42,7 +42,7 @@ const {
           v-model="form.orderNo"
           placeholder="请输入订单编号"
           clearable
-          class="w-[180px]!"
+          class="w-[150px]!"
         />
       </el-form-item>
       <el-form-item label="手机号:" prop="phone">
@@ -50,7 +50,7 @@ const {
           v-model="form.phone"
           placeholder="请输入手机号"
           clearable
-          class="w-[180px]!"
+          class="w-[150px]!"
         />
       </el-form-item>
       <el-form-item label="设备ID:" prop="deviceId">
@@ -58,7 +58,7 @@ const {
           v-model="form.deviceId"
           placeholder="请输入设备ID"
           clearable
-          class="w-[180px]!"
+          class="w-[150px]!"
         />
       </el-form-item>
       <el-form-item label="支付状态:" prop="payStatus">
@@ -66,7 +66,7 @@ const {
           v-model="form.payStatus"
           placeholder="请选择"
           clearable
-          class="w-[180px]!"
+          class="w-[140px]!"
         >
           <el-option label="已支付" value="paid" />
           <el-option label="待支付" value="unpaid" />
@@ -77,7 +77,7 @@ const {
           v-model="form.status"
           placeholder="请选择"
           clearable
-          class="w-[180px]!"
+          class="w-[140px]!"
         >
           <el-option label="待支付" :value="0" />
           <el-option label="已完成" :value="1" />
@@ -93,7 +93,7 @@ const {
           start-placeholder="开始日期"
           end-placeholder="结束日期"
           value-format="YYYY-MM-DD"
-          class="w-[240px]!"
+          class="w-[210px]!"
           @change="(val) => {
             form.startDate = val ? val[0] : '';
             form.endDate = val ? val[1] : '';

+ 5 - 0
haha-common/src/main/java/com/haha/common/constant/RedisConstants.java

@@ -33,6 +33,11 @@ public final class RedisConstants {
     /** 设备信息缓存键前缀 */
     public static final String DEVICE_INFO_KEY_PREFIX = "device:info:";
     
+    // ==================== 设备同步相关 ====================
+
+    /** 设备同步防抖键前缀,参数:设备ID */
+    public static final String DEVICE_SYNC_DEBOUNCE = "device:sync:debounce:";
+
     // ==================== 设备告警相关 ====================
     
     /** 设备告警冷却键前缀,参数:设备ID、告警类型 */

+ 131 - 0
haha-mp/CLAUDE.md

@@ -0,0 +1,131 @@
+# AI零售柜 - 智能视觉零售小程序
+
+## 项目概述
+
+一个基于 **uni-app 3 + Vue 3 + TypeScript + Pinia** 的跨平台小程序,实现"AI智能零售柜 · 即拿即走"的无人零售体验。用户扫码开门 -> 取走商品 -> 关门 -> AI自动识别 -> 微信支付分扣款。
+
+## 技术栈
+
+- **框架**: uni-app 3.0 (DCloud)
+- **核心**: Vue 3.5 + TypeScript + Vite 5
+- **状态管理**: Pinia
+- **样式**: SCSS (自定义设计系统)
+- **目标平台**: 微信小程序 (主)、H5、支付宝小程序等
+
+## 目录结构
+
+```
+haha-mp/
+├── src/
+│   ├── api/           # API 接口层
+│   │   ├── device.ts       # 设备相关
+│   │   ├── order.ts        # 订单相关
+│   │   ├── user.ts         # 用户/登录
+│   │   ├── payscore.ts     # 微信支付分
+│   │   ├── coupon.ts       # 优惠券
+│   │   ├── announcement.ts # 公告
+│   │   ├── status.ts       # 状态轮询
+│   │   └── replenish.ts    # 补货员
+│   ├── pages/         # 页面
+│   │   ├── index/          # 首页 (扫码入口)
+│   │   ├── shopping/       # 购物流程页
+│   │   ├── products/       # 柜内商品陈列
+│   │   ├── login/          # 登录
+│   │   ├── my/             # 个人中心
+│   │   ├── orders/         # 订单列表
+│   │   ├── orderDetail/    # 订单详情
+│   │   ├── refund/         # 退款
+│   │   ├── payscore/       # 开通微信支付分
+│   │   ├── coupons/        # 我的优惠券
+│   │   ├── couponCenter/   # 领券中心
+│   │   ├── announcement/   # 系统公告
+│   │   ├── announcementDetail/ # 公告详情
+│   │   ├── agreement/      # 用户协议/privacy
+│   │   └── replenish/      # 补货员绑定
+│   ├── utils/         # 工具类
+│   │   ├── config.ts       # API配置 & 路径常量
+│   │   ├── request.ts      # HTTP请求封装
+│   │   ├── auth.ts         # 认证/token管理
+│   │   ├── order.ts        # 订单工具函数
+│   │   ├── coupon.ts       # 优惠券工具函数
+│   │   └── logger.ts       # 日志工具
+│   ├── components/    # 公共组件
+│   ├── styles/        # 样式
+│   ├── static/        # 静态资源
+│   ├── App.vue        # 应用入口(启动逻辑/登录守卫)
+│   ├── main.ts        # 入口文件
+│   ├── pages.json     # 页面路由配置
+│   ├── manifest.json  # 应用配置
+│   └── uni.scss       # 设计系统变量
+├── package.json
+├── tsconfig.json
+└── index.html
+```
+
+## 业务架构
+
+### 核心业务流程
+
+1. **扫码开门**: 用户扫码 -> 检查登录 -> 检查微信支付分(需550+) -> 后端开门 -> 进入购物页
+2. **选购过程**: 开门后60秒倒计时 -> 轮询设备状态等待关门
+3. **关门结算**: 门关 -> 轮询AI识别结果 -> 轮询订单信息 -> 展示结算结果
+4. **查看订单**: 订单列表(待支付/已完成/已取消) -> 订单详情 -> 退款(7天内)
+
+### 页面路由
+
+| 路径 | 功能 | 导航栏标题 | 免登录? |
+|------|------|-----------|---------|
+| /pages/index/index | 首页(扫码入口) | AI零售柜 | 否(扫码时校验) |
+| /pages/login/login | 微信手机号登录 | 登录 | 是 |
+| /pages/my/my | 个人中心 | (空) | 否 |
+| /pages/shopping/shopping | 购物流程 | 购物进行中 | 否 |
+| /pages/products/products | 柜内商品陈列 | 柜内商品 | 是 |
+| /pages/orders/orders | 订单列表 | 订单 | 否 |
+| /pages/orderDetail/orderDetail | 订单详情 | 订单详情 | 否 |
+| /pages/refund/refund | 退款 | 退款商品 | 否 |
+| /pages/payscore/enable | 开通微信支付分 | 开通微信支付分 | 否 |
+| /pages/coupons/coupons | 我的优惠券 | 我的优惠券 | 否 |
+| /pages/couponCenter/couponCenter | 领券中心 | 领券中心 | 否 |
+| /pages/announcement/announcement | 系统公告 | 系统公告 | 否 |
+| /pages/announcementDetail/announcementDetail | 公告详情 | 公告详情 | 否 |
+| /pages/agreement/serviceAgreement | 用户服务协议 | 用户服务协议 | 是 |
+| /pages/agreement/privacyPolicy | 隐私政策 | 隐私政策 | 是 |
+| /pages/replenish/bind | 补货员绑定 | 上货员绑定 | 是 |
+
+### 后端API (dev环境)
+
+- **Base URL**: `https://dev-haha.kuaiyuman.cn/api`
+- 统一返回格式: `{ code: number, message: string, data?: T }`
+- 认证方式: header `accessToken`
+- 业务码: 200/0=成功, 401=未授权
+
+### API 模块
+
+| 模块 | 主要接口 | 说明 |
+|------|---------|------|
+| **用户** | miniapp-phone, user-info, logout | 微信手机号一键登录 |
+| **设备** | scan-open, status, products/{id} | 扫码开门&商品陈列 |
+| **订单** | list, detail, cancel | 订单CRUD |
+| **支付分** | check-enable, enable, create, confirm-enable | 微信支付分开通&下单 |
+| **优惠券** | my, receive/{id}, available, usable, count | 优惠券领取&使用 |
+| **状态轮询** | device, recognize, order, all | 实时状态查询(轮询) |
+| **公告** | list, detail | 系统公告 |
+| **补货员** | login/wechat, login/bind, my-info | 补货员身份绑定 |
+
+### 状态轮询机制
+
+核心购物流程依赖轮询:
+- **设备状态**: 2秒间隔, 120秒超时 -> 等待关门
+- **识别结果**: 1秒间隔, 60秒超时 -> AI识别商品
+- **订单信息**: 1秒间隔, 60秒超时 -> 生成订单
+
+### 设计系统(uni.scss)
+
+- 主色: `#FFC107` (暖黄)
+- 风格: 极简留白 · 优雅动效 · 微交互
+- 功能已开发占位: 会员卡、卡片、发票管理、常见问题、公众号信息
+
+### 辅助角色
+
+- **补货员(Replenisher)**: 通过管理后台二维码绑定身份,独立登录体系
+- **客服**: `400-0755-315`

+ 155 - 53
haha-mp/src/api/status.ts

@@ -60,7 +60,7 @@ export interface AllStatusResponse {
  * @param deviceId 设备ID
  */
 export const queryDeviceStatus = (deviceId: string): Promise<DeviceStatusResponse> => {
-  return post<DeviceStatusResponse>('/status/device', { deviceId });
+  return post<DeviceStatusResponse>('/status/device', { deviceId }, { silent: true });
 };
 
 /**
@@ -68,7 +68,7 @@ export const queryDeviceStatus = (deviceId: string): Promise<DeviceStatusRespons
  * @param activityId 活动ID
  */
 export const queryRecognizeResult = (activityId: string): Promise<RecognizeResultResponse> => {
-  return post<RecognizeResultResponse>('/status/recognize', { activityId });
+  return post<RecognizeResultResponse>('/status/recognize', { activityId }, { silent: true });
 };
 
 /**
@@ -77,7 +77,7 @@ export const queryRecognizeResult = (activityId: string): Promise<RecognizeResul
  * @param activityId 活动ID(与orderId二选一)
  */
 export const queryOrderInfo = (orderId?: string, activityId?: string): Promise<OrderInfoResponse> => {
-  return post<OrderInfoResponse>('/status/order', { orderId, activityId });
+  return post<OrderInfoResponse>('/status/order', { orderId, activityId }, { silent: true });
 };
 
 /**
@@ -85,7 +85,7 @@ export const queryOrderInfo = (orderId?: string, activityId?: string): Promise<O
  * @param deviceId 设备ID
  */
 export const queryAllStatus = (deviceId: string): Promise<AllStatusResponse> => {
-  return post<AllStatusResponse>('/status/all', { deviceId });
+  return post<AllStatusResponse>('/status/all', { deviceId }, { silent: true });
 };
 
 /**
@@ -96,44 +96,78 @@ export const queryAllStatus = (deviceId: string): Promise<AllStatusResponse> =>
  */
 export const pollDeviceStatus = (
   deviceId: string,
-  timeout: number = 120000,
-  interval: number = 2000
+  timeout: number = 60000,
+  interval: number = 3000,
+  maxConsecutiveFailures: number = 3
 ): Promise<DeviceStatusResponse> => {
   return new Promise((resolve, reject) => {
     const startTime = Date.now();
-    
+    let timerId: ReturnType<typeof setTimeout> | null = null;
+    let settled = false;
+    let failCount = 0;
+
+    const cleanup = () => {
+      if (timerId !== null) {
+        clearTimeout(timerId);
+        timerId = null;
+      }
+    };
+
+    const done = (result: DeviceStatusResponse | null, err?: Error) => {
+      if (settled) return;
+      settled = true;
+      cleanup();
+      if (err) {
+        reject(err);
+      } else if (result) {
+        resolve(result);
+      }
+    };
+
     const poll = async () => {
+      if (settled) return;
+
       try {
         const response = await queryDeviceStatus(deviceId);
-        
-        // 检查是否有有效的状态更新
+
+        if (settled) return;
+
+        // 成功一次就重置失败计数
+        failCount = 0;
+
         if (response && response.doorStatus && response.doorStatus !== 'unknown') {
           const statusTime = parseInt(response.timestamp) || 0;
-          // 如果状态更新时间在开始轮询之后,说明是新状态
           if (statusTime > startTime - 10000) {
-            resolve(response);
+            done(response);
             return;
           }
         }
-        
-        // 检查是否超时
+
         if (Date.now() - startTime >= timeout) {
-          reject(new Error('轮询超时'));
+          done(null, new Error('轮询超时'));
           return;
         }
-        
-        // 继续轮询
-        setTimeout(poll, interval);
+
+        timerId = setTimeout(poll, interval);
       } catch (error) {
-        // 查询失败,继续轮询
+        if (settled) return;
+
+        failCount++;
+
+        if (failCount >= maxConsecutiveFailures) {
+          done(null, new Error('网络异常,轮询中断'));
+          return;
+        }
+
         if (Date.now() - startTime >= timeout) {
-          reject(new Error('轮询超时'));
+          done(null, new Error('轮询超时'));
           return;
         }
-        setTimeout(poll, interval);
+
+        timerId = setTimeout(poll, interval);
       }
     };
-    
+
     poll();
   });
 };
@@ -146,40 +180,74 @@ export const pollDeviceStatus = (
  */
 export const pollRecognizeResult = (
   activityId: string,
-  timeout: number = 60000,
-  interval: number = 1000
+  timeout: number = 30000,
+  interval: number = 3000,
+  maxConsecutiveFailures: number = 3
 ): Promise<RecognizeResultResponse> => {
   return new Promise((resolve, reject) => {
     const startTime = Date.now();
-    
+    let timerId: ReturnType<typeof setTimeout> | null = null;
+    let settled = false;
+    let failCount = 0;
+
+    const cleanup = () => {
+      if (timerId !== null) {
+        clearTimeout(timerId);
+        timerId = null;
+      }
+    };
+
+    const done = (result: RecognizeResultResponse | null, err?: Error) => {
+      if (settled) return;
+      settled = true;
+      cleanup();
+      if (err) {
+        reject(err);
+      } else if (result) {
+        resolve(result);
+      }
+    };
+
     const poll = async () => {
+      if (settled) return;
+
       try {
         const result = await queryRecognizeResult(activityId);
-        
-        // 检查是否有识别结果
+
+        if (settled) return;
+
+        failCount = 0;
+
         if (result && result.result) {
-          resolve(result);
+          done(result);
           return;
         }
-        
-        // 检查是否超时
+
         if (Date.now() - startTime >= timeout) {
-          reject(new Error('识别超时'));
+          done(null, new Error('识别超时'));
           return;
         }
-        
-        // 继续轮询
-        setTimeout(poll, interval);
+
+        timerId = setTimeout(poll, interval);
       } catch (error) {
-        // 查询失败,继续轮询
+        if (settled) return;
+
+        failCount++;
+
+        if (failCount >= maxConsecutiveFailures) {
+          done(null, new Error('网络异常,轮询中断'));
+          return;
+        }
+
         if (Date.now() - startTime >= timeout) {
-          reject(new Error('识别超时'));
+          done(null, new Error('识别超时'));
           return;
         }
-        setTimeout(poll, interval);
+
+        timerId = setTimeout(poll, interval);
       }
     };
-    
+
     poll();
   });
 };
@@ -192,40 +260,74 @@ export const pollRecognizeResult = (
  */
 export const pollOrderInfo = (
   activityId: string,
-  timeout: number = 60000,
-  interval: number = 1000
+  timeout: number = 30000,
+  interval: number = 3000,
+  maxConsecutiveFailures: number = 3
 ): Promise<OrderInfoResponse> => {
   return new Promise((resolve, reject) => {
     const startTime = Date.now();
-    
+    let timerId: ReturnType<typeof setTimeout> | null = null;
+    let settled = false;
+    let failCount = 0;
+
+    const cleanup = () => {
+      if (timerId !== null) {
+        clearTimeout(timerId);
+        timerId = null;
+      }
+    };
+
+    const done = (result: OrderInfoResponse | null, err?: Error) => {
+      if (settled) return;
+      settled = true;
+      cleanup();
+      if (err) {
+        reject(err);
+      } else if (result) {
+        resolve(result);
+      }
+    };
+
     const poll = async () => {
+      if (settled) return;
+
       try {
         const order = await queryOrderInfo(undefined, activityId);
-        
-        // 检查是否有订单信息
+
+        if (settled) return;
+
+        failCount = 0;
+
         if (order && order.orderId) {
-          resolve(order);
+          done(order);
           return;
         }
-        
-        // 检查是否超时
+
         if (Date.now() - startTime >= timeout) {
-          reject(new Error('获取订单超时'));
+          done(null, new Error('获取订单超时'));
           return;
         }
-        
-        // 继续轮询
-        setTimeout(poll, interval);
+
+        timerId = setTimeout(poll, interval);
       } catch (error) {
-        // 查询失败,继续轮询
+        if (settled) return;
+
+        failCount++;
+
+        if (failCount >= maxConsecutiveFailures) {
+          done(null, new Error('网络异常,轮询中断'));
+          return;
+        }
+
         if (Date.now() - startTime >= timeout) {
-          reject(new Error('获取订单超时'));
+          done(null, new Error('获取订单超时'));
           return;
         }
-        setTimeout(poll, interval);
+
+        timerId = setTimeout(poll, interval);
       }
     };
-    
+
     poll();
   });
 };

+ 7 - 8
haha-mp/src/pages/shopping/shopping.vue

@@ -119,7 +119,8 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue';
+import { ref, onMounted, onUnmounted } from 'vue';
+import { onShow, onHide } from '@dcloudio/uni-app';
 import { pollDeviceStatus, pollRecognizeResult, pollOrderInfo } from '../../api/status';
 import type { RecognizeResultResponse } from '../../api/status';
 
@@ -186,19 +187,17 @@ onUnmounted(() => {
   cleanupTimers();
 });
 
-// 页面获得焦点时重新激活轮询
-onActivated(() => {
+// 页面显示时重新激活轮询
+onShow(() => {
   if (doorStatus.value === 'opened' || doorStatus.value === 'closing') {
     isComponentActive = true;
-    // 重新开始轮询
     startStatusPolling();
   }
 });
 
-// 页面失去焦点时暂停轮询
-onDeactivated(() => {
+// 页面隐藏时暂停轮询(navigateTo 进入子页面时触发)
+onHide(() => {
   isComponentActive = false;
-  // 清理定时器
   cleanupTimers();
 });
 
@@ -232,7 +231,7 @@ const startStatusPolling = async () => {
   
   try {
     // 轮询设备状态,等待关门
-    const status = await pollDeviceStatus(currentDeviceId.value, 120000, 2000);
+    const status = await pollDeviceStatus(currentDeviceId.value, 60000, 3000);
     
     // 再次检查组件是否仍然活跃
     if (!isComponentActive) {

+ 34 - 36
haha-mp/src/utils/request.ts

@@ -17,6 +17,8 @@ interface RequestConfig {
   timeout?: number;
   /** 是否跳过添加token,用于免登录接口 */
   skipAuth?: boolean;
+  /** 是否静默(不弹 toast),轮询类请求应设为 true */
+  silent?: boolean;
 }
 
 /**
@@ -35,7 +37,9 @@ interface ResponseData<T = any> {
  */
 export const request = <T = any>(config: RequestConfig): Promise<T> => {
   return new Promise((resolve, reject) => {
-    const { url, method = 'GET', data, header = {}, timeout = API_CONFIG.timeout, skipAuth = false } = config;
+    const { url, method = 'GET', data, header = {}, timeout, skipAuth = false, silent = false } = config;
+
+    const effectiveTimeout = timeout ?? (silent ? 5000 : API_CONFIG.timeout);
 
     // 构建完整URL
     let fullUrl = url.startsWith('http') ? url : `${API_CONFIG.baseUrl}${url}`;
@@ -61,7 +65,7 @@ export const request = <T = any>(config: RequestConfig): Promise<T> => {
     }
 
     // 打印请求日志
-    if (API_CONFIG.enableLog) {
+    if (API_CONFIG.enableLog && !silent) {
       console.log(`[请求] ${method} ${fullUrl}`, data);
     }
 
@@ -71,10 +75,10 @@ export const request = <T = any>(config: RequestConfig): Promise<T> => {
       method,
       data,
       header,
-      timeout,
+      timeout: effectiveTimeout,
       success: (res: any) => {
         // 打印响应日志
-        if (API_CONFIG.enableLog) {
+        if (API_CONFIG.enableLog && !silent) {
           console.log(`[响应] ${method} ${fullUrl}`, res.data);
         }
 
@@ -83,54 +87,48 @@ export const request = <T = any>(config: RequestConfig): Promise<T> => {
         // 处理HTTP状态码
         if (res.statusCode !== 200) {
           const errorMsg = `请求失败: HTTP ${res.statusCode}`;
-          uni.showToast({
-            title: errorMsg,
-            icon: 'none'
-          });
+          if (!silent) {
+            uni.showToast({ title: errorMsg, icon: 'none' });
+          }
           reject(new Error(errorMsg));
           return;
         }
 
         // 处理业务状态码
         if (responseData.code === 200 || responseData.code === 0) {
-          // 成功
           if (API_CONFIG.enableLog) {
             console.log('[响应处理] 业务成功,返回data:', responseData.data);
           }
           resolve(responseData.data as T);
         } else if (responseData.code === 401) {
-          // 未登录或token过期 -> 全局未授权处理(清除登录信息并跳转登录页)
-          handleUnauthorized();
+          if (!silent) {
+            handleUnauthorized();
+          }
           reject(new Error(responseData.message || '未登录'));
         } else {
-          // 业务错误
           const errorMsg = responseData.message || '操作失败';
-          uni.showToast({
-            title: errorMsg,
-            icon: 'none'
-          });
+          if (!silent) {
+            uni.showToast({ title: errorMsg, icon: 'none' });
+          }
           reject(new Error(errorMsg));
         }
       },
       fail: (err: any) => {
-        // 网络错误
         console.error(`[请求失败] ${method} ${fullUrl}`, err);
 
-        let errorMsg = '网络请求失败';
-        if (err.errMsg) {
-          if (err.errMsg.includes('timeout')) {
-            errorMsg = '请求超时,请检查网络';
-          } else if (err.errMsg.includes('fail')) {
-            errorMsg = '网络连接失败,请检查网络';
+        if (!silent) {
+          let errorMsg = '网络请求失败';
+          if (err.errMsg) {
+            if (err.errMsg.includes('timeout')) {
+              errorMsg = '请求超时,请检查网络';
+            } else if (err.errMsg.includes('fail')) {
+              errorMsg = '网络连接失败,请检查网络';
+            }
           }
+          uni.showToast({ title: errorMsg, icon: 'none' });
         }
 
-        uni.showToast({
-          title: errorMsg,
-          icon: 'none'
-        });
-
-        reject(new Error(errorMsg));
+        reject(new Error(err.errMsg || '网络请求失败'));
       }
     });
   });
@@ -139,27 +137,27 @@ export const request = <T = any>(config: RequestConfig): Promise<T> => {
 /**
  * GET请求
  */
-export const get = <T = any>(url: string, data?: any, options?: { skipAuth?: boolean }): Promise<T> => {
+export const get = <T = any>(url: string, data?: any, options?: { skipAuth?: boolean; silent?: boolean }): Promise<T> => {
   return request<T>({ url, method: 'GET', data, ...options });
 };
 
 /**
  * POST请求
  */
-export const post = <T = any>(url: string, data?: any): Promise<T> => {
-  return request<T>({ url, method: 'POST', data });
+export const post = <T = any>(url: string, data?: any, options?: { silent?: boolean }): Promise<T> => {
+  return request<T>({ url, method: 'POST', data, ...options });
 };
 
 /**
  * PUT请求
  */
-export const put = <T = any>(url: string, data?: any): Promise<T> => {
-  return request<T>({ url, method: 'PUT', data });
+export const put = <T = any>(url: string, data?: any, options?: { silent?: boolean }): Promise<T> => {
+  return request<T>({ url, method: 'PUT', data, ...options });
 };
 
 /**
  * DELETE请求
  */
-export const del = <T = any>(url: string, data?: any): Promise<T> => {
-  return request<T>({ url, method: 'DELETE', data });
+export const del = <T = any>(url: string, data?: any, options?: { silent?: boolean }): Promise<T> => {
+  return request<T>({ url, method: 'DELETE', data, ...options });
 };

+ 10 - 1
haha-service/src/main/java/com/haha/service/DataSyncService.java

@@ -55,8 +55,17 @@ public interface DataSyncService extends IService<SyncRecord> {
 
     /**
      * 获取同步统计信息
-     * 
+     *
      * @return 统计信息
      */
     Map<String, Object> getSyncStatistics();
+
+    /**
+     * 按设备编号同步单个设备
+     * 从哈哈平台拉取设备列表并搜索指定设备,若存在则同步到本地数据库
+     *
+     * @param deviceId 设备编号
+     * @return true 同步成功,false 跳过(防抖拦截或设备未找到)
+     */
+    boolean syncDeviceBySn(String deviceId);
 }

+ 56 - 0
haha-service/src/main/java/com/haha/service/impl/DataSyncServiceImpl.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.haha.common.constant.RedisConstants;
 import com.haha.common.exception.BusinessException;
 import com.haha.entity.Device;
 import com.haha.entity.Product;
@@ -20,6 +21,8 @@ import com.haha.service.DeviceService;
 import com.haha.service.ProductService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -27,6 +30,7 @@ import java.time.LocalDateTime;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 数据同步服务实现
@@ -40,6 +44,7 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
     private final DeviceService deviceService;
     private final ProductService productService;
     private final SyncLogMapper syncLogMapper;
+    private final StringRedisTemplate stringRedisTemplate;
 
     /**
      * 同步状态常量
@@ -427,4 +432,55 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
             return null;
         }
     }
+
+    /**
+     * 按设备编号同步单个设备(异步)
+     * 从哈哈平台设备列表搜索指定设备,若存在且本地未登记则自动入库
+     *
+     * @param deviceId 设备编号
+     * @return true 创建成功,false 跳过
+     */
+    @Async("taskExecutor")
+    @Override
+    public boolean syncDeviceBySn(String deviceId) {
+        String debounceKey = RedisConstants.DEVICE_SYNC_DEBOUNCE + deviceId;
+        Boolean locked = stringRedisTemplate.opsForValue()
+                .setIfAbsent(debounceKey, "1", 5, TimeUnit.MINUTES);
+        if (locked == null || !locked) {
+            log.debug("设备同步防抖中,跳过: {}", deviceId);
+            return false;
+        }
+
+        try {
+            List<DeviceInfo> deviceList = hahaClient.getDeviceApi().getDeviceList(1, 200);
+            DeviceInfo target = deviceList.stream()
+                    .filter(d -> deviceId.equals(d.getId()))
+                    .findFirst()
+                    .orElse(null);
+
+            if (target == null) {
+                log.warn("自动同步:设备 {} 在哈哈平台设备列表中未找到", deviceId);
+                return false;
+            }
+
+            Device existDevice = deviceService.getDeviceBySn(deviceId);
+            if (existDevice != null) {
+                log.info("自动同步:设备 {} 已存在,跳过创建", deviceId);
+                return false;
+            }
+
+            Device newDevice = new Device();
+            newDevice.setDeviceId(target.getId());
+            newDevice.setName(target.getName());
+            newDevice.setAddress(target.getAddress());
+            newDevice.setStatus("1".equals(target.getStatus()) ? 1 : 0);
+            deviceService.save(newDevice);
+
+            log.info("自动同步:设备 {} ({}) 创建成功", deviceId, target.getName());
+            return true;
+        } catch (Exception e) {
+            log.error("自动同步:设备 {} 同步失败", deviceId, e);
+            return false;
+        }
+    }
 }

+ 12 - 0
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -11,6 +11,7 @@ import com.haha.common.enums.PaymentChannel;
 import com.haha.common.enums.ProductAuditStatus;
 import com.haha.common.enums.RecognizeConsumeType;
 import com.haha.entity.Order;
+import com.haha.entity.Device;
 import com.haha.entity.OrderGoods;
 import com.haha.entity.DoorRecord;
 import com.haha.entity.UserCoupon;
@@ -20,6 +21,7 @@ import com.haha.mapper.OrderGoodsMapper;
 import com.haha.sdk.HahaClient;
 import com.haha.service.DeviceAlertService;
 import com.haha.service.HahaCallbackService;
+import com.haha.service.DataSyncService;
 import com.haha.service.NewProductApplyService;
 import com.haha.service.OrderService;
 import com.haha.service.DoorRecordService;
@@ -94,6 +96,9 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
     @Autowired
     private DeviceAlertProperties alertProperties;
 
+    @Autowired
+    private DataSyncService dataSyncService;
+
     @Override
     public void handleMessage(Map<String, Object> params) {
         // 签名验证:确保回调来自哈哈平台
@@ -204,6 +209,13 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
                 // 设备离线 -> 触发离线告警
                 deviceAlertService.processOfflineAlert(deviceId, "CALLBACK");
             } else if (isOnline == 1) {
+                // 设备上线 -> 检查是否为新设备,自动同步
+                Device localDevice = deviceMapper.selectByDeviceId(deviceId);
+                if (localDevice == null) {
+                    log.info("设备 {} 在本地不存在,触发异步同步", deviceId);
+                    dataSyncService.syncDeviceBySn(deviceId);
+                }
+
                 // 设备上线 -> 检查是否需要发送恢复通知
                 deviceAlertService.processBackOnlineNotify(deviceId);