浏览代码

智能柜项目提交

skyline 3 月之前
父节点
当前提交
4b0c979b2d
共有 37 个文件被更改,包括 2810 次插入8 次删除
  1. 153 0
      UX设计规范书.md
  2. 6 0
      haha-admin/pom.xml
  3. 271 0
      haha-admin/src/main/java/com/haha/admin/aspect/OperationLogAspect.java
  4. 6 0
      haha-admin/src/main/java/com/haha/admin/controller/AdminController.java
  5. 4 0
      haha-admin/src/main/java/com/haha/admin/controller/AdminLoginController.java
  6. 188 0
      haha-admin/src/main/java/com/haha/admin/controller/AnnouncementController.java
  7. 4 0
      haha-admin/src/main/java/com/haha/admin/controller/DataSyncController.java
  8. 3 0
      haha-admin/src/main/java/com/haha/admin/controller/DeviceController.java
  9. 4 0
      haha-admin/src/main/java/com/haha/admin/controller/InventoryController.java
  10. 6 0
      haha-admin/src/main/java/com/haha/admin/controller/NewProductApplyController.java
  11. 115 0
      haha-admin/src/main/java/com/haha/admin/controller/OperationLogController.java
  12. 3 0
      haha-admin/src/main/java/com/haha/admin/controller/OrderController.java
  13. 6 0
      haha-admin/src/main/java/com/haha/admin/controller/ProductController.java
  14. 5 0
      haha-admin/src/main/java/com/haha/admin/controller/RoleController.java
  15. 15 0
      haha-admin/src/main/java/com/haha/admin/controller/ShopController.java
  16. 4 0
      haha-admin/src/main/java/com/haha/admin/controller/UserController.java
  17. 121 0
      haha-admin/src/main/java/com/haha/admin/utils/IpUtils.java
  18. 8 8
      haha-admin/src/main/resources/application.yml
  19. 29 0
      haha-admin/src/main/resources/sql/operation_log.sql
  20. 40 0
      haha-common/src/main/java/com/haha/common/annotation/Log.java
  21. 72 0
      haha-common/src/main/java/com/haha/common/enums/OperationType.java
  22. 115 0
      haha-entity/src/main/java/com/haha/entity/Announcement.java
  23. 120 0
      haha-entity/src/main/java/com/haha/entity/OperationLog.java
  24. 20 0
      haha-mapper/src/main/java/com/haha/mapper/AnnouncementMapper.java
  25. 39 0
      haha-mapper/src/main/java/com/haha/mapper/OperationLogMapper.java
  26. 77 0
      haha-miniapp/src/main/java/com/haha/miniapp/controller/AppAnnouncementController.java
  27. 42 0
      haha-mp/src/api/announcement.ts
  28. 16 0
      haha-mp/src/pages.json
  29. 191 0
      haha-mp/src/pages/announcement/announcement.vue
  30. 169 0
      haha-mp/src/pages/announcementDetail/announcementDetail.vue
  31. 67 0
      haha-service/src/main/java/com/haha/service/AnnouncementService.java
  32. 84 0
      haha-service/src/main/java/com/haha/service/OperationLogService.java
  33. 191 0
      haha-service/src/main/java/com/haha/service/impl/AnnouncementServiceImpl.java
  34. 154 0
      haha-service/src/main/java/com/haha/service/impl/OperationLogServiceImpl.java
  35. 8 0
      pom.xml
  36. 218 0
      开发指导.md
  37. 236 0
      需求文档.md

+ 153 - 0
UX设计规范书.md

@@ -0,0 +1,153 @@
+这是一份专为 **UI/UX 设计师** 撰写的《智能视觉售卖机系统 UI 设计参考规范》。
+
+这份文档侧重于**视觉风格、交互体验、关键页面布局及特殊状态的处理**,旨在确保设计方案既美观又符合“视觉识别+即拿即走”的特殊业务场景。
+
+---
+
+# 智能视觉售卖机系统 - UI/UX 设计规范书
+
+| 项目属性 | 定义 |
+| :--- | :--- |
+| **项目名称** | 智能视觉售卖机系统 |
+| **文档版本** | V1.0 (设计指导版) |
+| **设计核心** | **科技感 (Tech)**、**极简 (Minimalist)**、**信任感 (Trust)** |
+| **关键体验** | 减少焦虑感(处理识别延迟),强化证据链(视频展示) |
+| **适应终端** | 微信小程序 (C端/B端)、PC Web (B端管理后台) |
+
+---
+
+## 1. 视觉体系定义 (Visual Identity)
+
+### 1.1 设计关键词
+*   **无感 (Seamless)**: 流程流畅,没有多余的点击。
+*   **通透 (Transparent)**: 价格透明、扣款透明、证据透明(建立对AI的信任)。
+*   **响应 (Responsive)**: 每一个操作(开门、关门、结算)都有明确的视觉反馈。
+
+### 1.2 建议配色方案
+由于涉及“支付”和“AI”,建议使用**蓝色系**作为主色,代表科技与安全;**绿色**代表通过/开门;**橙红色**用于警示/异常。
+
+*   **主色调 (Brand Color)**: `Technology Blue` (例如 `#2979FF` 或 `#0052D9`) - 用于核心按钮、选中状态。
+*   **辅助色 (Success)**: `Signal Green` (例如 `#00C853`) - 用于“门已开”、“支付成功”。
+*   **辅助色 (Warning)**: `Alert Orange` (例如 `#FF9100`) - 用于“补货差异”、“识别存疑”。
+*   **中性色**:
+    *   `Black` (#333333): 主标题。
+    *   `Grey` (#909399): 次要信息、辅助说明。
+    *   `Light Grey` (#F5F7FA): 背景色。
+
+### 1.3 字体与图标
+*   **数字字体**: 涉及金额、倒计时的数字,建议使用 **DIN** 或 **Roboto Mono** 等等宽字体,强调金融属性。
+*   **图标风格**: 建议使用 **线性图标 (Outline)**,选中状态为 **填充 (Filled)**。线条需圆润,减少锐利感。
+
+---
+
+## 2. 用户端小程序 (C端) - 界面设计要点
+
+### 2.1 首页 (Home)
+首页即是“开门钥匙”,需极致简化。
+*   **背景**: 全屏地图 (Map),展示附近的点位锚点 (Marker)。
+*   **核心操作区 (Bottom Sheet)**:
+    *   **扫码按钮**: **巨大化设计**,悬浮在底部中央,带有呼吸动效或阴影,暗示“点击这里开始”。
+    *   **辅助信息**: 扫码按钮下方展示“微信支付分 | 550分以上免押金”字样,增加用户信任。
+*   **侧边/角落入口**: 个人中心(头像)、客服(耳机图标)、附近列表。
+
+### 2.2 状态机页面 (购物全流程 - 核心难点)
+这是一个根据状态变化的**动态页面**,切勿设计成多个割裂的页面。
+
+*   **状态 A: 开门申请中 (Loading)**
+    *   **视觉**: 锁定屏幕,中间显示圆形进度条。
+    *   **文案**: “正在安全检测...”、“正在解锁...”。
+*   **状态 B: 门已开 (Shopping)**
+    *   **视觉**: 
+        *   背景色转为**柔和的绿色渐变**。
+        *   中央大图标:**开锁图标** 或 **购物袋图标**。
+        *   **醒目文案**: “门已开,请选购”。
+        *   **底部提示**: “选购完成后,关门自动结算”。
+*   **状态 C: 识别结算中 (Processing - 需缓解焦虑)**
+    *   *注:此状态可能持续 5-15 秒。*
+    *   **视觉**:
+        *   **核心动效**: 一个“AI大脑”或“扫描光线”在不断分析商品的动画。
+        *   **文案轮播**: “AI正在清点商品...” -> “正在核对价格...” -> “即将生成账单...”。
+    *   **禁止**: 不要使用死板的“加载中”转圈,要让用户感觉系统在“思考”。
+*   **状态 D: 结算完成 (Receipt)**
+    *   **布局**: 小票样式 (Receipt Card)。
+    *   **顶部**: 支付金额(大字)。
+    *   **中部**: 商品列表(缩略图 + 名称 + 数量 + 单价)。
+    *   **底部**: “查看购物监控”按钮(线框按钮)。
+
+### 2.3 订单详情与证据链
+*   **视频播放器**:
+    *   放在页面顶部或显眼位置。
+    *   **默认状态**: 展示封面图 + 播放按钮 + “购物过程回放”标签。
+    *   **播放状态**: 简易播放器,支持全屏。
+*   **售后入口**:
+    *   在商品列表下方,设计“商品识别有误?”的文字链接。
+
+---
+
+## 3. 商户运营端 (PC Web) - 界面设计要点
+
+### 3.1 布局框架
+*   使用 **侧边栏 (Sidebar) + 顶部导航 (Navbar)** 的经典布局(类似 Ant Design Pro / Element Plus admin)。
+*   **风格**: 干净、高对比度,适合长时间操作。
+
+### 3.2 商品管理 (AI 模型关联)
+此页面不同于普通电商后台。
+*   **图片上传区**: 
+    *   需强调图片规范。设计一个虚线框区域,并在旁边展示**“正确示例(白底)” vs “错误示例(杂乱背景)”**的对比图。
+*   **状态标签**:
+    *   设计 distinct 的状态 Tag:`未同步` (灰)、`训练中` (蓝)、`已生效` (绿)、`训练失败` (红)。
+
+### 3.3 挂起订单审核页 (人工介入)
+这是一个**分屏操作页**,用于处理AI识别不准的订单。
+*   **左侧 (40% 宽度)**: **视频播放区**。需支持逐帧播放、倍速播放。
+*   **右侧 (60% 宽度)**: **识别结果修正区**。
+    *   显示 AI 识别出的商品列表(带删除按钮)。
+    *   提供“添加商品”的搜索框。
+    *   底部固定栏:显示“修正后总金额”,以及“确认扣款”与“取消订单”按钮。
+
+---
+
+## 4. 商家小程序 (B端) - 界面设计要点
+
+### 4.1 运维工作台
+*   **卡片式设计**: 每个设备是一个卡片。
+*   **状态指示灯**: 卡片右上角用颜色点表示状态(绿=在线,灰=离线,红=缺货/故障)。
+*   **关键数据**: 卡片内展示“当前SKU数 / 满载率”。
+
+### 4.2 补货确认页 (Before/After)
+运维关门后,系统会推送补货结果。
+*   **视觉重点**: 突出**“变化量”**。
+*   **列表样式**:
+    *   商品 A: `库存 5 -> 10` (+5) [高亮绿色]
+    *   商品 B: `库存 3 -> 2` (-1) [高亮红色,可能是拿走了]
+*   **修正交互**: 每一行提供 `+` `-` 步进器,允许运维快速修正数字。
+
+---
+
+## 5. 组件与动效规范 (Interaction Specs)
+
+### 5.1 弹窗 (Dialog/Modal)
+*   **C端**: 使用底部弹窗 (Half-screen Dialog/Action Sheet) 为主,例如选择支付方式、查看优惠券。
+*   **B端**: 使用居中弹窗,用于确认操作。
+
+### 5.2 骨架屏 (Skeleton)
+*   在地图加载、订单列表加载、商品详情加载时,**必须**使用骨架屏过渡,避免页面白屏闪烁。
+
+### 5.3 异常反馈 (Toast/Alert)
+*   **轻微提示**: 顶部下滑 Toast (如“网络连接已恢复”)。
+*   **阻断提示**: 居中 Modal (如“您有未支付订单,请先处理”),配上警示插画。
+
+---
+
+## 6. 设计交付物清单
+
+1.  **UI Kit**: 包含标准色板、字体规范、通用按钮、表单输入框、Iconfont/SVG图标集。
+2.  **关键页面高保真图 (Hi-Fi)**:
+    *   C端: 首页扫码态、开门鉴权弹窗、购物倒计时态、结算清单页、订单详情视频页。
+    *   B端 PC: 仪表盘、商品上传页、视频审核页。
+    *   B端 Mobile: 运维设备列表、补货差异核对页。
+3.  **动效 Demo**: “AI识别中”的加载动画演示 (MP4/Lottie/Gif)。
+
+---
+
+这份文档请设计师结合具体的 UI 框架(如 uView UI 或 Element Plus)进行落地设计。

+ 6 - 0
haha-admin/pom.xml

@@ -97,6 +97,12 @@
             <groupId>org.apache.commons</groupId>
             <artifactId>commons-pool2</artifactId>
         </dependency>
+
+        <!-- AspectJ AOP -->
+        <dependency>
+            <groupId>org.aspectj</groupId>
+            <artifactId>aspectjweaver</artifactId>
+        </dependency>
     </dependencies>
 
     <build>

+ 271 - 0
haha-admin/src/main/java/com/haha/admin/aspect/OperationLogAspect.java

@@ -0,0 +1,271 @@
+package com.haha.admin.aspect;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.alibaba.fastjson2.JSON;
+import com.haha.admin.utils.IpUtils;
+import com.haha.common.annotation.Log;
+import com.haha.entity.OperationLog;
+import com.haha.service.OperationLogService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.AfterThrowing;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.lang.reflect.Method;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 操作日志切面
+ * 拦截带有@Log注解的方法,记录操作日志
+ */
+@Slf4j
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class OperationLogAspect {
+
+    private final OperationLogService operationLogService;
+    
+    /**
+     * 记录方法开始时间的ThreadLocal
+     */
+    private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
+
+    /**
+     * 切点:所有带有@Log注解的方法
+     */
+    @Pointcut("@annotation(com.haha.common.annotation.Log)")
+    public void logPointcut() {
+    }
+
+    /**
+     * 方法执行后记录日志
+     */
+    @AfterReturning(pointcut = "logPointcut()", returning = "result")
+    public void doAfterReturning(JoinPoint joinPoint, Object result) {
+        handleLog(joinPoint, null, result);
+    }
+
+    /**
+     * 方法抛出异常后记录日志
+     */
+    @AfterThrowing(pointcut = "logPointcut()", throwing = "e")
+    public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
+        handleLog(joinPoint, e, null);
+    }
+
+    /**
+     * 处理日志记录
+     */
+    private void handleLog(JoinPoint joinPoint, Exception e, Object result) {
+        try {
+            // 获取注解信息
+            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+            Method method = signature.getMethod();
+            Log logAnnotation = method.getAnnotation(Log.class);
+            
+            if (logAnnotation == null) {
+                return;
+            }
+
+            // 创建日志对象
+            OperationLog operationLog = new OperationLog();
+            
+            // 设置基本信息
+            operationLog.setModule(logAnnotation.module());
+            operationLog.setOperationType(logAnnotation.operation().getDescription());
+            operationLog.setSummary(logAnnotation.summary());
+            operationLog.setCreateTime(LocalDateTime.now());
+            
+            // 获取请求信息
+            HttpServletRequest request = getHttpServletRequest();
+            if (request != null) {
+                operationLog.setRequestUrl(request.getRequestURI());
+                operationLog.setRequestMethod(request.getMethod());
+                operationLog.setIp(IpUtils.getIpAddr(request));
+                operationLog.setAddress(IpUtils.getIpAddress(request));
+                
+                // 解析User-Agent
+                String userAgent = request.getHeader("User-Agent");
+                if (userAgent != null) {
+                    parseUserAgent(userAgent, operationLog);
+                }
+                
+                // 设置请求参数
+                if (logAnnotation.saveParams()) {
+                    String params = getRequestParams(joinPoint, request);
+                    operationLog.setRequestParams(params);
+                }
+            }
+            
+            // 设置操作人信息
+            try {
+                Object loginId = StpUtil.getLoginIdDefaultNull();
+                if (loginId != null) {
+                    operationLog.setAdminId(Long.parseLong(loginId.toString()));
+                    // 获取用户名 - 尝试从session或token中获取
+                    Object username = StpUtil.getSession().get("username");
+                    if (username != null) {
+                        operationLog.setAdminName(username.toString());
+                    }
+                }
+            } catch (Exception ex) {
+                log.debug("获取登录用户信息失败: {}", ex.getMessage());
+            }
+            
+            // 设置操作状态
+            if (e != null) {
+                operationLog.setStatus(OperationLog.STATUS_FAIL);
+                String errorMsg = e.getMessage();
+                if (errorMsg != null && errorMsg.length() > 500) {
+                    errorMsg = errorMsg.substring(0, 500);
+                }
+                operationLog.setErrorMsg(errorMsg);
+            } else {
+                operationLog.setStatus(OperationLog.STATUS_SUCCESS);
+            }
+            
+            // 设置响应结果
+            if (logAnnotation.saveResult() && result != null) {
+                try {
+                    String resultStr = JSON.toJSONString(result);
+                    if (resultStr.length() > 2000) {
+                        resultStr = resultStr.substring(0, 2000);
+                    }
+                    operationLog.setResponseResult(resultStr);
+                } catch (Exception ex) {
+                    log.debug("序列化响应结果失败: {}", ex.getMessage());
+                }
+            }
+            
+            // 异步保存日志
+            saveLogAsync(operationLog);
+            
+        } catch (Exception ex) {
+            log.error("记录操作日志异常: {}", ex.getMessage(), ex);
+        }
+    }
+    
+    /**
+     * 异步保存日志
+     */
+    @Async
+    public void saveLogAsync(OperationLog operationLog) {
+        try {
+            operationLogService.save(operationLog);
+        } catch (Exception e) {
+            log.error("保存操作日志失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 获取HttpServletRequest对象
+     */
+    private HttpServletRequest getHttpServletRequest() {
+        try {
+            jakarta.servlet.http.HttpServletRequest request = 
+                (jakarta.servlet.http.HttpServletRequest) org.springframework.web.context.request.RequestContextHolder
+                    .currentRequestAttributes()
+                    .resolveReference(org.springframework.web.context.request.RequestAttributes.REFERENCE_REQUEST);
+            return request;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 获取请求参数
+     */
+    private String getRequestParams(JoinPoint joinPoint, HttpServletRequest request) {
+        try {
+            Object[] args = joinPoint.getArgs();
+            if (args == null || args.length == 0) {
+                return null;
+            }
+            
+            Map<String, Object> params = new HashMap<>();
+            String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
+            
+            for (int i = 0; i < args.length; i++) {
+                Object arg = args[i];
+                String paramName = (paramNames != null && i < paramNames.length) ? paramNames[i] : "arg" + i;
+                
+                // 过滤不需要的参数
+                if (arg instanceof HttpServletRequest 
+                    || arg instanceof HttpServletResponse 
+                    || arg instanceof MultipartFile) {
+                    continue;
+                }
+                
+                try {
+                    params.put(paramName, arg);
+                } catch (Exception e) {
+                    params.put(paramName, arg.getClass().getName());
+                }
+            }
+            
+            String paramsStr = JSON.toJSONString(params);
+            if (paramsStr.length() > 2000) {
+                paramsStr = paramsStr.substring(0, 2000);
+            }
+            return paramsStr;
+        } catch (Exception e) {
+            log.debug("获取请求参数失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 解析User-Agent
+     */
+    private void parseUserAgent(String userAgent, OperationLog operationLog) {
+        if (userAgent == null) {
+            return;
+        }
+        
+        userAgent = userAgent.toLowerCase();
+        
+        // 解析操作系统
+        if (userAgent.contains("windows")) {
+            operationLog.setOs("Windows");
+        } else if (userAgent.contains("mac")) {
+            operationLog.setOs("Mac OS");
+        } else if (userAgent.contains("linux")) {
+            operationLog.setOs("Linux");
+        } else if (userAgent.contains("android")) {
+            operationLog.setOs("Android");
+        } else if (userAgent.contains("iphone") || userAgent.contains("ipad")) {
+            operationLog.setOs("iOS");
+        } else {
+            operationLog.setOs("Unknown");
+        }
+        
+        // 解析浏览器
+        if (userAgent.contains("edge")) {
+            operationLog.setBrowser("Edge");
+        } else if (userAgent.contains("chrome")) {
+            operationLog.setBrowser("Chrome");
+        } else if (userAgent.contains("firefox")) {
+            operationLog.setBrowser("Firefox");
+        } else if (userAgent.contains("safari") && !userAgent.contains("chrome")) {
+            operationLog.setBrowser("Safari");
+        } else if (userAgent.contains("opera") || userAgent.contains("opr")) {
+            operationLog.setBrowser("Opera");
+        } else if (userAgent.contains("msie") || userAgent.contains("trident")) {
+            operationLog.setBrowser("IE");
+        } else {
+            operationLog.setBrowser("Unknown");
+        }
+    }
+}

+ 6 - 0
haha-admin/src/main/java/com/haha/admin/controller/AdminController.java

@@ -2,6 +2,8 @@ package com.haha.admin.controller;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.haha.service.AdminService;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.Admin;
 import lombok.extern.slf4j.Slf4j;
@@ -66,6 +68,7 @@ public class AdminController {
     /**
      * 添加管理员
      */
+    @Log(module = "用户管理", operation = OperationType.INSERT, summary = "添加管理员")
     @PostMapping("/add")
     public Result<Void> add(@RequestBody Admin admin) {
         return adminService.addAdmin(admin);
@@ -74,6 +77,7 @@ public class AdminController {
     /**
      * 更新管理员
      */
+    @Log(module = "用户管理", operation = OperationType.UPDATE, summary = "更新管理员")
     @PostMapping("/update")
     public Result<Void> update(@RequestBody Admin admin) {
         return adminService.updateAdmin(admin);
@@ -82,6 +86,7 @@ public class AdminController {
     /**
      * 删除管理员
      */
+    @Log(module = "用户管理", operation = OperationType.DELETE, summary = "删除管理员")
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
         return adminService.deleteAdmin(id);
@@ -90,6 +95,7 @@ public class AdminController {
     /**
      * 重置密码
      */
+    @Log(module = "用户管理", operation = OperationType.UPDATE, summary = "重置管理员密码")
     @PostMapping("/reset-password")
     public Result<Void> resetPassword(@RequestBody Map<String, Object> params) {
         Long id = Long.valueOf(params.get("id").toString());

+ 4 - 0
haha-admin/src/main/java/com/haha/admin/controller/AdminLoginController.java

@@ -4,6 +4,8 @@ import cn.dev33.satoken.annotation.SaIgnore;
 import cn.dev33.satoken.stp.StpUtil;
 import com.haha.admin.dto.AdminLoginDTO;
 import com.haha.admin.service.AdminLoginService;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.LoginVO;
 import com.haha.common.vo.Result;
 import lombok.extern.slf4j.Slf4j;
@@ -29,6 +31,7 @@ public class AdminLoginController {
      * @return 登录结果,包含token和用户信息
      */
     @SaIgnore
+    @Log(module = "系统登录", operation = OperationType.LOGIN, summary = "管理员登录")
     @PostMapping
     public Result<LoginVO> login(@RequestBody AdminLoginDTO loginDTO) {
         log.info("收到登录请求: username={}", loginDTO.getUsername());
@@ -39,6 +42,7 @@ public class AdminLoginController {
      * 退出登录
      * @return 退出结果
      */
+    @Log(module = "系统登录", operation = OperationType.LOGOUT, summary = "管理员退出登录")
     @PostMapping("/logout")
     public Result<Void> logout() {
         try {

+ 188 - 0
haha-admin/src/main/java/com/haha/admin/controller/AnnouncementController.java

@@ -0,0 +1,188 @@
+package com.haha.admin.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.common.vo.Result;
+import com.haha.entity.Announcement;
+import com.haha.service.AnnouncementService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 系统公告管理控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/announcement")
+public class AnnouncementController {
+
+    @Autowired
+    private AnnouncementService announcementService;
+
+    /**
+     * 分页查询公告列表
+     */
+    @GetMapping("/list")
+    public Result<Map<String, Object>> list(@RequestParam Map<String, Object> params) {
+        int page = 1;
+        int pageSize = 10;
+
+        Object pageObj = params.get("page");
+        if (pageObj != null && !pageObj.toString().isEmpty()) {
+            page = Integer.parseInt(pageObj.toString());
+        }
+        Object pageSizeObj = params.get("pageSize");
+        if (pageSizeObj != null && !pageSizeObj.toString().isEmpty()) {
+            pageSize = Integer.parseInt(pageSizeObj.toString());
+        }
+
+        IPage<Announcement> pageResult = announcementService.getPage(page, pageSize, params);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("list", pageResult.getRecords());
+        result.put("total", pageResult.getTotal());
+        result.put("page", page);
+        result.put("pageSize", pageSize);
+
+        return Result.success(result);
+    }
+
+    /**
+     * 获取公告详情
+     */
+    @GetMapping("/{id}")
+    public Result<Announcement> getDetail(@PathVariable Long id) {
+        Announcement announcement = announcementService.getById(id);
+        if (announcement == null) {
+            return Result.error("公告不存在");
+        }
+        return Result.success(announcement);
+    }
+
+    /**
+     * 创建公告
+     */
+    @PostMapping
+    public Result<Announcement> create(@RequestBody Announcement announcement) {
+        try {
+            announcement.setStatus(0); // 默认草稿状态
+            announcement.setIsTop(0);  // 默认不置顶
+            announcement.setReadCount(0);
+            announcement.setCreateTime(LocalDateTime.now());
+            announcement.setUpdateTime(LocalDateTime.now());
+
+            announcementService.save(announcement);
+            return Result.success("创建成功", announcement);
+        } catch (Exception e) {
+            log.error("创建公告失败", e);
+            return Result.error("创建失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 更新公告
+     */
+    @PutMapping("/{id}")
+    public Result<Announcement> update(@PathVariable Long id, @RequestBody Announcement announcement) {
+        try {
+            Announcement existing = announcementService.getById(id);
+            if (existing == null) {
+                return Result.error("公告不存在");
+            }
+
+            // 只更新允许修改的字段
+            existing.setTitle(announcement.getTitle());
+            existing.setContent(announcement.getContent());
+            existing.setType(announcement.getType());
+            existing.setCoverImage(announcement.getCoverImage());
+            existing.setUpdateTime(LocalDateTime.now());
+
+            announcementService.updateById(existing);
+            return Result.success("更新成功", existing);
+        } catch (Exception e) {
+            log.error("更新公告失败", e);
+            return Result.error("更新失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 删除公告
+     */
+    @DeleteMapping("/{id}")
+    public Result<String> delete(@PathVariable Long id) {
+        try {
+            Announcement existing = announcementService.getById(id);
+            if (existing == null) {
+                return Result.error("公告不存在");
+            }
+
+            announcementService.removeById(id);
+            return Result.success("删除成功");
+        } catch (Exception e) {
+            log.error("删除公告失败", e);
+            return Result.error("删除失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 发布公告
+     */
+    @PutMapping("/{id}/publish")
+    public Result<String> publish(@PathVariable Long id) {
+        try {
+            Long userId = StpUtil.getLoginIdAsLong();
+            String userName = StpUtil.getLoginIdAsString();
+
+            boolean success = announcementService.publish(id, userId, userName);
+            if (success) {
+                return Result.success("发布成功");
+            } else {
+                return Result.error("发布失败,公告不存在");
+            }
+        } catch (Exception e) {
+            log.error("发布公告失败", e);
+            return Result.error("发布失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 下线公告
+     */
+    @PutMapping("/{id}/offline")
+    public Result<String> offline(@PathVariable Long id) {
+        try {
+            boolean success = announcementService.offline(id);
+            if (success) {
+                return Result.success("下线成功");
+            } else {
+                return Result.error("下线失败,公告不存在");
+            }
+        } catch (Exception e) {
+            log.error("下线公告失败", e);
+            return Result.error("下线失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 置顶/取消置顶
+     */
+    @PutMapping("/{id}/top")
+    public Result<String> setTop(@PathVariable Long id, @RequestParam Integer isTop) {
+        try {
+            boolean success = announcementService.setTop(id, isTop);
+            if (success) {
+                return Result.success(isTop == 1 ? "置顶成功" : "取消置顶成功");
+            } else {
+                return Result.error("操作失败,公告不存在");
+            }
+        } catch (Exception e) {
+            log.error("置顶操作失败", e);
+            return Result.error("操作失败:" + e.getMessage());
+        }
+    }
+}

+ 4 - 0
haha-admin/src/main/java/com/haha/admin/controller/DataSyncController.java

@@ -1,6 +1,8 @@
 package com.haha.admin.controller;
 
 import cn.dev33.satoken.stp.StpUtil;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.SyncRecord;
 import com.haha.service.DataSyncService;
@@ -25,6 +27,7 @@ public class DataSyncController {
      * 同步设备数据
      * 从哈哈平台拉取设备列表并存储到本地数据库
      */
+    @Log(module = "数据同步", operation = OperationType.SYNC, summary = "同步设备数据")
     @PostMapping("/devices")
     public Result<SyncRecord> syncDevices() {
         try {
@@ -58,6 +61,7 @@ public class DataSyncController {
      * 同步商品数据
      * 从哈哈平台商家商品库拉取商品列表并存储到本地数据库
      */
+    @Log(module = "数据同步", operation = OperationType.SYNC, summary = "同步商品数据")
     @PostMapping("/products")
     public Result<SyncRecord> syncProducts() {
         try {

+ 3 - 0
haha-admin/src/main/java/com/haha/admin/controller/DeviceController.java

@@ -3,6 +3,8 @@ package com.haha.admin.controller;
 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.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.Device;
 import com.haha.entity.Shop;
@@ -159,6 +161,7 @@ public class DeviceController {
      * @param params 开门参数
      * @return 操作结果
      */
+    @Log(module = "设备管理", operation = OperationType.OTHER, summary = "远程开门")
     @PostMapping("/{id}/open")
     public Result<Void> openDoor(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {

+ 4 - 0
haha-admin/src/main/java/com/haha/admin/controller/InventoryController.java

@@ -2,6 +2,8 @@ package com.haha.admin.controller;
 
 import cn.dev33.satoken.stp.StpUtil;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.DeviceInventory;
 import com.haha.entity.InventoryLog;
@@ -132,6 +134,7 @@ public class InventoryController {
     /**
      * 增加库存(上货)
      */
+    @Log(module = "库存管理", operation = OperationType.INSERT, summary = "上货增加库存")
     @PostMapping("/increase")
     public Result<DeviceInventory> increaseStock(@RequestBody Map<String, Object> params) {
         try {
@@ -163,6 +166,7 @@ public class InventoryController {
     /**
      * 调整库存
      */
+    @Log(module = "库存管理", operation = OperationType.UPDATE, summary = "调整库存")
     @PostMapping("/adjust")
     public Result<DeviceInventory> adjustStock(@RequestBody Map<String, Object> params) {
         try {

+ 6 - 0
haha-admin/src/main/java/com/haha/admin/controller/NewProductApplyController.java

@@ -2,6 +2,8 @@ package com.haha.admin.controller;
 
 import cn.dev33.satoken.stp.StpUtil;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.NewProductApply;
 import com.haha.service.NewProductApplyService;
@@ -80,6 +82,7 @@ public class NewProductApplyController {
      * @param apply 申请信息
      * @return 申请ID
      */
+    @Log(module = "新品申请", operation = OperationType.INSERT, summary = "提交新品申请")
     @PostMapping("/submit")
     public Result<Long> submit(@RequestBody NewProductApply apply) {
         try {
@@ -100,6 +103,7 @@ public class NewProductApplyController {
      * @param apply 申请信息
      * @return 申请ID
      */
+    @Log(module = "新品申请", operation = OperationType.INSERT, summary = "保存新品申请草稿")
     @PostMapping("/save-draft")
     public Result<Long> saveDraft(@RequestBody NewProductApply apply) {
         try {
@@ -125,6 +129,7 @@ public class NewProductApplyController {
      * @param apply 申请信息
      * @return 是否成功
      */
+    @Log(module = "新品申请", operation = OperationType.UPDATE, summary = "更新新品申请")
     @PutMapping("/{id}")
     public Result<Boolean> update(@PathVariable Long id, @RequestBody NewProductApply apply) {
         try {
@@ -152,6 +157,7 @@ public class NewProductApplyController {
      * @param id 申请ID
      * @return 是否成功
      */
+    @Log(module = "新品申请", operation = OperationType.DELETE, summary = "删除新品申请")
     @DeleteMapping("/{id}")
     public Result<Boolean> delete(@PathVariable Long id) {
         try {

+ 115 - 0
haha-admin/src/main/java/com/haha/admin/controller/OperationLogController.java

@@ -0,0 +1,115 @@
+package com.haha.admin.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
+import com.haha.common.vo.Result;
+import com.haha.entity.OperationLog;
+import com.haha.service.OperationLogService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 操作日志控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/operation-log")
+@RequiredArgsConstructor
+public class OperationLogController {
+
+    private final OperationLogService operationLogService;
+
+    /**
+     * 分页查询操作日志列表
+     */
+    @GetMapping("/list")
+    public Result<Map<String, Object>> list(
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam(required = false) String module,
+            @RequestParam(required = false) String username,
+            @RequestParam(required = false) Integer status,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+        
+        Page<OperationLog> pageParam = new Page<>(page, pageSize);
+        Page<OperationLog> result = operationLogService.pageList(pageParam, module, username, status, startTime, endTime);
+        
+        Map<String, Object> data = Map.of(
+                "list", result.getRecords(),
+                "total", result.getTotal(),
+                "page", result.getCurrent(),
+                "pageSize", result.getSize()
+        );
+        
+        return Result.success(data);
+    }
+
+    /**
+     * 获取操作日志详情
+     */
+    @GetMapping("/{id}")
+    public Result<OperationLog> detail(@PathVariable Long id) {
+        OperationLog operationLog = operationLogService.getById(id);
+        if (operationLog == null) {
+            return Result.error("日志不存在");
+        }
+        return Result.success(operationLog);
+    }
+
+    /**
+     * 批量删除操作日志
+     */
+    @Log(module = "系统监控", operation = OperationType.DELETE, summary = "批量删除操作日志")
+    @DeleteMapping("/batch")
+    public Result<Void> deleteBatch(@RequestBody List<Long> ids) {
+        operationLogService.deleteBatch(ids);
+        return Result.success("删除成功", null);
+    }
+
+    /**
+     * 删除单条操作日志
+     */
+    @Log(module = "系统监控", operation = OperationType.DELETE, summary = "删除操作日志")
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(@PathVariable Long id) {
+        operationLogService.deleteBatch(List.of(id));
+        return Result.success("删除成功", null);
+    }
+
+    /**
+     * 清空所有操作日志
+     */
+    @Log(module = "系统监控", operation = OperationType.DELETE, summary = "清空操作日志")
+    @DeleteMapping("/clear")
+    public Result<Void> clearAll() {
+        operationLogService.clearAll();
+        return Result.success("清空成功", null);
+    }
+
+    /**
+     * 清空指定时间之前的日志
+     */
+    @Log(module = "系统监控", operation = OperationType.DELETE, summary = "清空历史操作日志")
+    @DeleteMapping("/clear-before")
+    public Result<Void> clearBeforeTime(@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime beforeTime) {
+        operationLogService.clearBeforeTime(beforeTime);
+        return Result.success("清空成功", null);
+    }
+
+    /**
+     * 获取操作日志统计数据
+     */
+    @GetMapping("/statistics")
+    public Result<Map<String, Object>> statistics() {
+        Map<String, Object> data = operationLogService.getStatistics();
+        return Result.success(data);
+    }
+}

+ 3 - 0
haha-admin/src/main/java/com/haha/admin/controller/OrderController.java

@@ -3,6 +3,8 @@ package com.haha.admin.controller;
 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.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.Order;
 import com.haha.service.OrderService;
@@ -207,6 +209,7 @@ public class OrderController {
      * @param params 退款参数
      * @return 退款结果
      */
+    @Log(module = "订单管理", operation = OperationType.UPDATE, summary = "订单退款")
     @PostMapping("/{id}/refund")
     public Result<Void> refund(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {

+ 6 - 0
haha-admin/src/main/java/com/haha/admin/controller/ProductController.java

@@ -3,6 +3,8 @@ package com.haha.admin.controller;
 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.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.Product;
 import com.haha.service.ProductService;
@@ -146,6 +148,7 @@ public class ProductController {
      * @param product 商品信息
      * @return 添加结果
      */
+    @Log(module = "商品管理", operation = OperationType.INSERT, summary = "添加商品")
     @PostMapping
     public Result<Product> add(@RequestBody Product product) {
         try {
@@ -172,6 +175,7 @@ public class ProductController {
      * @param product 商品信息
      * @return 编辑结果
      */
+    @Log(module = "商品管理", operation = OperationType.UPDATE, summary = "编辑商品")
     @PutMapping("/{id}")
     public Result<Product> update(@PathVariable Long id, @RequestBody Product product) {
         try {
@@ -200,6 +204,7 @@ public class ProductController {
      * @param id 商品ID
      * @return 删除结果
      */
+    @Log(module = "商品管理", operation = OperationType.DELETE, summary = "删除商品")
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
         try {
@@ -225,6 +230,7 @@ public class ProductController {
      * @param id 商品ID
      * @return 同步结果
      */
+    @Log(module = "商品管理", operation = OperationType.SYNC, summary = "同步商品")
     @PostMapping("/{id}/sync")
     public Result<Void> sync(@PathVariable Long id) {
         try {

+ 5 - 0
haha-admin/src/main/java/com/haha/admin/controller/RoleController.java

@@ -2,6 +2,8 @@ package com.haha.admin.controller;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.haha.service.RoleService;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.Role;
 import lombok.extern.slf4j.Slf4j;
@@ -59,6 +61,7 @@ public class RoleController {
     /**
      * 添加角色
      */
+    @Log(module = "角色管理", operation = OperationType.INSERT, summary = "添加角色")
     @PostMapping("/add")
     public Result<Void> add(@RequestBody Role role) {
         return roleService.addRole(role);
@@ -67,6 +70,7 @@ public class RoleController {
     /**
      * 更新角色
      */
+    @Log(module = "角色管理", operation = OperationType.UPDATE, summary = "更新角色")
     @PostMapping("/update")
     public Result<Void> update(@RequestBody Role role) {
         return roleService.updateRole(role);
@@ -75,6 +79,7 @@ public class RoleController {
     /**
      * 删除角色
      */
+    @Log(module = "角色管理", operation = OperationType.DELETE, summary = "删除角色")
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
         return roleService.deleteRole(id);

+ 15 - 0
haha-admin/src/main/java/com/haha/admin/controller/ShopController.java

@@ -2,6 +2,8 @@ package com.haha.admin.controller;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.Shop;
 import com.haha.entity.Device;
@@ -130,6 +132,7 @@ public class ShopController {
      * @param shop 门店信息
      * @return 新增后的门店
      */
+    @Log(module = "门店管理", operation = OperationType.INSERT, summary = "新增门店")
     @PostMapping
     public Result<Shop> create(@RequestBody Shop shop) {
         try {
@@ -147,6 +150,7 @@ public class ShopController {
      * @param shop 门店信息
      * @return 编辑后的门店
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "编辑门店")
     @PutMapping("/{id}")
     public Result<Shop> update(@PathVariable Long id, @RequestBody Shop shop) {
         try {
@@ -164,6 +168,7 @@ public class ShopController {
      * @param id 门店ID
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.DELETE, summary = "删除门店")
     @DeleteMapping("/{id}")
     public Result<String> delete(@PathVariable Long id) {
         try {
@@ -191,6 +196,7 @@ public class ShopController {
      * @param params 状态参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "切换门店状态")
     @PutMapping("/{id}/status")
     public Result<String> toggleStatus(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -230,6 +236,7 @@ public class ShopController {
      * @param params 设备ID参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "关联设备到门店")
     @PostMapping("/{id}/devices")
     public Result<String> linkDevice(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -252,6 +259,7 @@ public class ShopController {
      * @param params 设备ID列表参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "批量关联设备到门店")
     @PostMapping("/{id}/devices/batch")
     public Result<Map<String, Object>> batchLinkDevices(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -280,6 +288,7 @@ public class ShopController {
      * @param deviceId 设备ID
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "移除门店设备关联")
     @DeleteMapping("/{id}/devices/{deviceId}")
     public Result<String> unlinkDevice(@PathVariable Long id, @PathVariable Long deviceId) {
         try {
@@ -301,6 +310,7 @@ public class ShopController {
      * @param params 设备ID列表参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "批量移除设备关联")
     @DeleteMapping("/{id}/devices/batch")
     public Result<Map<String, Object>> batchUnlinkDevices(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -362,6 +372,7 @@ public class ShopController {
      * @param params 管理员ID参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.INSERT, summary = "添加门店补货员")
     @PostMapping("/{id}/replenishers")
     public Result<String> addReplenisher(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -379,6 +390,7 @@ public class ShopController {
      * @param params 管理员ID列表参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.INSERT, summary = "批量添加门店补货员")
     @PostMapping("/{id}/replenishers/batch")
     public Result<Map<String, Object>> batchAddReplenishers(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -407,6 +419,7 @@ public class ShopController {
      * @param adminId 管理员ID
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.DELETE, summary = "移除门店补货员")
     @DeleteMapping("/{id}/replenishers/{adminId}")
     public Result<String> removeReplenisher(@PathVariable Long id, @PathVariable Long adminId) {
         try {
@@ -423,6 +436,7 @@ public class ShopController {
      * @param params 管理员ID列表参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.DELETE, summary = "批量移除门店补货员")
     @DeleteMapping("/{id}/replenishers/batch")
     public Result<Map<String, Object>> batchRemoveReplenishers(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -456,6 +470,7 @@ public class ShopController {
      * @param params 状态参数
      * @return 操作结果
      */
+    @Log(module = "门店管理", operation = OperationType.UPDATE, summary = "更新补货员状态")
     @PutMapping("/replenishers/{id}/status")
     public Result<String> updateReplenisherStatus(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {

+ 4 - 0
haha-admin/src/main/java/com/haha/admin/controller/UserController.java

@@ -2,6 +2,8 @@ package com.haha.admin.controller;
 
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.common.annotation.Log;
+import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
 import com.haha.entity.User;
 import com.haha.service.UserService;
@@ -106,6 +108,7 @@ public class UserController {
      * @param params 状态参数
      * @return 操作结果
      */
+    @Log(module = "客户管理", operation = OperationType.UPDATE, summary = "更新客户状态")
     @PutMapping("/{id}/status")
     public Result<String> updateStatus(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {
@@ -132,6 +135,7 @@ public class UserController {
      * @param params 信用分参数
      * @return 操作结果
      */
+    @Log(module = "客户管理", operation = OperationType.UPDATE, summary = "更新客户信用分")
     @PutMapping("/{id}/credit")
     public Result<String> updateCreditScore(@PathVariable Long id, @RequestBody Map<String, Object> params) {
         try {

+ 121 - 0
haha-admin/src/main/java/com/haha/admin/utils/IpUtils.java

@@ -0,0 +1,121 @@
+package com.haha.admin.utils;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/**
+ * IP工具类
+ */
+public class IpUtils {
+
+    private static final String UNKNOWN = "unknown";
+    private static final String LOCALHOST_IP = "127.0.0.1";
+    private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
+    private static final int IP_MAX_LENGTH = 15;
+
+    /**
+     * 获取客户端IP地址
+     */
+    public static String getIpAddr(HttpServletRequest request) {
+        if (request == null) {
+            return UNKNOWN;
+        }
+        
+        String ip = request.getHeader("X-Forwarded-For");
+        if (isEmptyIp(ip)) {
+            ip = request.getHeader("Proxy-Client-IP");
+        }
+        if (isEmptyIp(ip)) {
+            ip = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (isEmptyIp(ip)) {
+            ip = request.getHeader("HTTP_CLIENT_IP");
+        }
+        if (isEmptyIp(ip)) {
+            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
+        }
+        if (isEmptyIp(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (isEmptyIp(ip)) {
+            ip = request.getRemoteAddr();
+            if (LOCALHOST_IP.equals(ip) || LOCALHOST_IPV6.equals(ip)) {
+                // 根据网卡取本机配置的IP
+                try {
+                    InetAddress inet = InetAddress.getLocalHost();
+                    ip = inet.getHostAddress();
+                } catch (UnknownHostException e) {
+                    // 忽略异常
+                }
+            }
+        }
+        
+        // 对于通过多个代理的情况,第一个IP才是客户端真实IP
+        if (ip != null && ip.length() > IP_MAX_LENGTH) {
+            int index = ip.indexOf(",");
+            if (index > 0) {
+                ip = ip.substring(0, index);
+            }
+        }
+        
+        return ip;
+    }
+
+    /**
+     * 判断IP是否为空
+     */
+    private static boolean isEmptyIp(String ip) {
+        return ip == null || ip.isEmpty() || UNKNOWN.equalsIgnoreCase(ip);
+    }
+
+    /**
+     * 获取当前请求
+     */
+    public static HttpServletRequest getRequest() {
+        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        return attributes != null ? attributes.getRequest() : null;
+    }
+
+    /**
+     * 根据IP获取地理位置
+     * 简单实现,可以接入第三方IP定位服务
+     */
+    public static String getIpAddress(HttpServletRequest request) {
+        String ip = getIpAddr(request);
+        
+        // 本地IP直接返回
+        if (LOCALHOST_IP.equals(ip) || LOCALHOST_IPV6.equals(ip)) {
+            return "本地";
+        }
+        
+        // 内网IP段判断
+        if (ip.startsWith("192.168.")) {
+            return "内网";
+        }
+        if (ip.startsWith("10.")) {
+            return "内网";
+        }
+        if (ip.startsWith("172.")) {
+            // 172.16.0.0 - 172.31.255.255
+            String[] parts = ip.split("\\.");
+            if (parts.length >= 2) {
+                try {
+                    int second = Integer.parseInt(parts[1]);
+                    if (second >= 16 && second <= 31) {
+                        return "内网";
+                    }
+                } catch (NumberFormatException e) {
+                    // 忽略
+                }
+            }
+        }
+        
+        // 实际项目中可以接入IP定位API
+        // 这里返回"未知"作为默认值
+        return "未知";
+    }
+}

+ 8 - 8
haha-admin/src/main/resources/application.yml

@@ -32,22 +32,22 @@ spring:
       port: 6379
       password: KtXA^Zx!TZmLEy(@JjB@2(TVG0kdy5)&
       database: 9
-      timeout: 10000
+      timeout: 30000
       # 连接配置
       lettuce:
         pool:
-          max-active: 10
-          max-wait: 3000
-          max-idle: 8
-          min-idle: 2
+          max-active: 20
+          max-wait: 5000
+          max-idle: 10
+          min-idle: 5
           # 连接耗尽时等待时间(毫秒)
-          time-between-eviction-runs: 30000
+          time-between-eviction-runs: 60000
         # 关闭超时时间
-        shutdown-timeout: 100
+        shutdown-timeout: 200
         # 刷新配置
         refresh:
           # 刷新周期(毫秒)
-          period: 10000
+          period: 30000
           # 最小刷新间隔
           min: 5000
 

+ 29 - 0
haha-admin/src/main/resources/sql/operation_log.sql

@@ -0,0 +1,29 @@
+-- 操作日志表
+CREATE TABLE IF NOT EXISTS t_operation_log (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
+  admin_id BIGINT COMMENT '操作人ID',
+  admin_name VARCHAR(50) COMMENT '操作人用户名',
+  module VARCHAR(100) COMMENT '所属模块',
+  operation_type VARCHAR(100) COMMENT '操作类型',
+  summary VARCHAR(500) COMMENT '操作概要',
+  request_url VARCHAR(255) COMMENT '请求URL',
+  request_method VARCHAR(10) COMMENT '请求方法',
+  request_params TEXT COMMENT '请求参数',
+  response_result TEXT COMMENT '响应结果',
+  ip VARCHAR(50) COMMENT '操作IP',
+  address VARCHAR(200) COMMENT '操作地点',
+  browser VARCHAR(100) COMMENT '浏览器类型',
+  os VARCHAR(100) COMMENT '操作系统',
+  status TINYINT DEFAULT 1 COMMENT '操作状态:1-成功,0-失败',
+  error_msg TEXT COMMENT '错误信息',
+  cost_time BIGINT COMMENT '耗时(毫秒)',
+  create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  INDEX idx_admin_id (admin_id),
+  INDEX idx_module (module),
+  INDEX idx_status (status),
+  INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
+
+-- 如果表已存在,执行以下ALTER语句添加缺失列
+-- ALTER TABLE t_operation_log ADD COLUMN admin_name VARCHAR(50) COMMENT '操作人用户名' AFTER admin_id;
+-- ALTER TABLE t_operation_log ADD COLUMN operation_type VARCHAR(100) COMMENT '操作类型' AFTER module;

+ 40 - 0
haha-common/src/main/java/com/haha/common/annotation/Log.java

@@ -0,0 +1,40 @@
+package com.haha.common.annotation;
+
+import com.haha.common.enums.OperationType;
+
+import java.lang.annotation.*;
+
+/**
+ * 操作日志注解
+ * 标记在Controller方法上,用于记录操作日志
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface Log {
+    
+    /**
+     * 操作模块
+     */
+    String module() default "";
+    
+    /**
+     * 操作类型
+     */
+    OperationType operation() default OperationType.OTHER;
+    
+    /**
+     * 操作概要
+     */
+    String summary() default "";
+    
+    /**
+     * 是否保存请求参数
+     */
+    boolean saveParams() default true;
+    
+    /**
+     * 是否保存响应结果
+     */
+    boolean saveResult() default true;
+}

+ 72 - 0
haha-common/src/main/java/com/haha/common/enums/OperationType.java

@@ -0,0 +1,72 @@
+package com.haha.common.enums;
+
+/**
+ * 操作类型枚举
+ */
+public enum OperationType {
+    
+    /**
+     * 登录
+     */
+    LOGIN("登录"),
+    
+    /**
+     * 登出
+     */
+    LOGOUT("登出"),
+    
+    /**
+     * 新增
+     */
+    INSERT("新增"),
+    
+    /**
+     * 修改
+     */
+    UPDATE("修改"),
+    
+    /**
+     * 删除
+     */
+    DELETE("删除"),
+    
+    /**
+     * 查询
+     */
+    SELECT("查询"),
+    
+    /**
+     * 导出
+     */
+    EXPORT("导出"),
+    
+    /**
+     * 导入
+     */
+    IMPORT("导入"),
+    
+    /**
+     * 审批
+     */
+    APPROVE("审批"),
+    
+    /**
+     * 同步
+     */
+    SYNC("同步"),
+    
+    /**
+     * 其他
+     */
+    OTHER("其他");
+    
+    private final String description;
+    
+    OperationType(String description) {
+        this.description = description;
+    }
+    
+    public String getDescription() {
+        return description;
+    }
+}

+ 115 - 0
haha-entity/src/main/java/com/haha/entity/Announcement.java

@@ -0,0 +1,115 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 系统公告实体类
+ */
+@Data
+@TableName("t_announcement")
+public class Announcement implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 公告标题
+     */
+    private String title;
+
+    /**
+     * 公告内容
+     */
+    private String content;
+
+    /**
+     * 公告类型:1-系统公告,2-活动公告,3-维护公告,4-其他
+     */
+    private Integer type;
+
+    /**
+     * 公告封面图片URL
+     */
+    private String coverImage;
+
+    /**
+     * 是否置顶:0-否,1-是
+     */
+    private Integer isTop;
+
+    /**
+     * 状态:0-草稿,1-已发布,2-已下线
+     */
+    private Integer status;
+
+    /**
+     * 发布时间
+     */
+    private LocalDateTime publishTime;
+
+    /**
+     * 下线时间
+     */
+    private LocalDateTime offlineTime;
+
+    /**
+     * 发布人ID
+     */
+    private Long publisherId;
+
+    /**
+     * 发布人名称
+     */
+    private String publisherName;
+
+    /**
+     * 阅读次数
+     */
+    private Integer readCount;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+
+    // ========== 以下为非数据库字段,用于前端展示 ==========
+
+    /**
+     * 公告类型标签
+     */
+    @TableField(exist = false)
+    private String typeLabel;
+
+    /**
+     * 公告类型颜色
+     */
+    @TableField(exist = false)
+    private String typeColor;
+
+    /**
+     * 状态标签
+     */
+    @TableField(exist = false)
+    private String statusLabel;
+
+    /**
+     * 状态颜色
+     */
+    @TableField(exist = false)
+    private String statusColor;
+}

+ 120 - 0
haha-entity/src/main/java/com/haha/entity/OperationLog.java

@@ -0,0 +1,120 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 操作日志实体类
+ */
+@Data
+@TableName("t_operation_log")
+public class OperationLog implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 操作人ID
+     */
+    private Long adminId;
+
+    /**
+     * 操作人用户名(对应数据库字段 admin_name)
+     */
+    private String adminName;
+
+    /**
+     * 所属模块
+     */
+    private String module;
+
+    /**
+     * 操作类型
+     */
+    @TableField("operation_type")
+    private String operationType;
+
+    /**
+     * 操作概要
+     */
+    private String summary;
+
+    /**
+     * 请求URL
+     */
+    private String requestUrl;
+
+    /**
+     * 请求方法
+     */
+    private String requestMethod;
+
+    /**
+     * 请求参数
+     */
+    private String requestParams;
+
+    /**
+     * 响应结果
+     */
+    private String responseResult;
+
+    /**
+     * 操作IP
+     */
+    private String ip;
+
+    /**
+     * 操作地点
+     */
+    private String address;
+
+    /**
+     * 浏览器类型
+     */
+    private String browser;
+
+    /**
+     * 操作系统
+     */
+    private String os;
+
+    /**
+     * 操作状态:1-成功,0-失败
+     */
+    private Integer status;
+
+    /**
+     * 错误信息
+     */
+    private String errorMsg;
+
+    /**
+     * 耗时(毫秒)
+     */
+    private Long costTime;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 操作状态常量:成功
+     */
+    public static final int STATUS_SUCCESS = 1;
+
+    /**
+     * 操作状态常量:失败
+     */
+    public static final int STATUS_FAIL = 0;
+}

+ 20 - 0
haha-mapper/src/main/java/com/haha/mapper/AnnouncementMapper.java

@@ -0,0 +1,20 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.Announcement;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * 系统公告Mapper
+ */
+@Mapper
+public interface AnnouncementMapper extends BaseMapper<Announcement> {
+
+    /**
+     * 增加阅读次数
+     */
+    @Update("UPDATE t_announcement SET read_count = read_count + 1 WHERE id = #{id}")
+    int incrementReadCount(@Param("id") Long id);
+}

+ 39 - 0
haha-mapper/src/main/java/com/haha/mapper/OperationLogMapper.java

@@ -0,0 +1,39 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.OperationLog;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 操作日志Mapper接口
+ */
+@Mapper
+public interface OperationLogMapper extends BaseMapper<OperationLog> {
+
+    /**
+     * 统计各模块操作次数
+     */
+    @Select("SELECT module, COUNT(*) as count FROM t_operation_log " +
+            "WHERE create_time >= #{startTime} GROUP BY module ORDER BY count DESC")
+    List<Map<String, Object>> countByModule(@Param("startTime") LocalDateTime startTime);
+
+    /**
+     * 统计操作成功失败数
+     */
+    @Select("SELECT status, COUNT(*) as count FROM t_operation_log " +
+            "WHERE create_time >= #{startTime} GROUP BY status")
+    List<Map<String, Object>> countByStatus(@Param("startTime") LocalDateTime startTime);
+
+    /**
+     * 统计每日操作数量
+     */
+    @Select("SELECT DATE(create_time) as date, COUNT(*) as count FROM t_operation_log " +
+            "WHERE create_time >= #{startTime} GROUP BY DATE(create_time) ORDER BY date")
+    List<Map<String, Object>> countByDate(@Param("startTime") LocalDateTime startTime);
+}

+ 77 - 0
haha-miniapp/src/main/java/com/haha/miniapp/controller/AppAnnouncementController.java

@@ -0,0 +1,77 @@
+package com.haha.miniapp.controller;
+
+import com.haha.entity.Announcement;
+import com.haha.service.AnnouncementService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 小程序公告接口
+ */
+@Slf4j
+@RestController
+@RequestMapping("/announcement")
+public class AppAnnouncementController {
+
+    @Autowired
+    private AnnouncementService announcementService;
+
+    /**
+     * 获取公告列表
+     *
+     * @param limit 数量限制,默认10条
+     * @return 公告列表
+     */
+    @GetMapping("/list")
+    public Map<String, Object> getList(@RequestParam(defaultValue = "10") int limit) {
+        Map<String, Object> result = new HashMap<>();
+
+        try {
+            List<Announcement> list = announcementService.getAppList(limit);
+            result.put("code", 200);
+            result.put("message", "success");
+            result.put("data", list);
+        } catch (Exception e) {
+            log.error("获取公告列表失败", e);
+            result.put("code", 500);
+            result.put("message", "获取公告列表失败");
+        }
+
+        return result;
+    }
+
+    /**
+     * 获取公告详情
+     *
+     * @param id 公告ID
+     * @return 公告详情
+     */
+    @GetMapping("/detail")
+    public Map<String, Object> getDetail(@RequestParam Long id) {
+        Map<String, Object> result = new HashMap<>();
+
+        try {
+            Announcement announcement = announcementService.getDetail(id);
+            if (announcement == null) {
+                result.put("code", 404);
+                result.put("message", "公告不存在");
+                return result;
+            }
+
+            result.put("code", 200);
+            result.put("message", "success");
+            result.put("data", announcement);
+        } catch (Exception e) {
+            log.error("获取公告详情失败", e);
+            result.put("code", 500);
+            result.put("message", "获取公告详情失败");
+        }
+
+        return result;
+    }
+}

+ 42 - 0
haha-mp/src/api/announcement.ts

@@ -0,0 +1,42 @@
+import { get } from '../utils/request';
+
+/**
+ * 公告相关接口
+ */
+
+/**
+ * 公告信息接口
+ */
+export interface Announcement {
+  id: number;
+  title: string;
+  content: string;
+  type: number;
+  typeLabel: string;
+  typeColor: string;
+  coverImage: string;
+  isTop: number;
+  status: number;
+  statusLabel: string;
+  statusColor: string;
+  publishTime: string | null;
+  publisherName?: string;
+  readCount: number;
+  createTime: string;
+}
+
+/**
+ * 获取公告列表
+ * @param limit 数量限制
+ */
+export const getAnnouncementList = (limit: number = 10): Promise<Announcement[]> => {
+  return get<Announcement[]>('/announcement/list', { limit });
+};
+
+/**
+ * 获取公告详情
+ * @param id 公告ID
+ */
+export const getAnnouncementDetail = (id: number): Promise<Announcement> => {
+  return get<Announcement>('/announcement/detail', { id });
+};

+ 16 - 0
haha-mp/src/pages.json

@@ -56,6 +56,22 @@
 				"navigationBarBackgroundColor": "#FFD700",
 				"navigationBarTextStyle": "black"
 			}
+		},
+		{
+			"path": "pages/announcement/announcement",
+			"style": {
+				"navigationBarTitleText": "系统公告",
+				"navigationBarBackgroundColor": "#FFD700",
+				"navigationBarTextStyle": "black"
+			}
+		},
+		{
+			"path": "pages/announcementDetail/announcementDetail",
+			"style": {
+				"navigationBarTitleText": "公告详情",
+				"navigationBarBackgroundColor": "#FFD700",
+				"navigationBarTextStyle": "black"
+			}
 		}
 	],
 	"globalStyle": {

+ 191 - 0
haha-mp/src/pages/announcement/announcement.vue

@@ -0,0 +1,191 @@
+<template>
+  <view class="announcement-list">
+    <!-- 加载中 -->
+    <view v-if="loading" class="loading-wrap">
+      <text>加载中...</text>
+    </view>
+
+    <!-- 公告列表 -->
+    <view v-else-if="announcementList.length > 0" class="list-wrap">
+      <view
+        v-for="item in announcementList"
+        :key="item.id"
+        class="announcement-item"
+        @click="goDetail(item)"
+      >
+        <view class="item-header">
+          <text class="type-tag" :class="'type-' + item.type">{{ item.typeLabel }}</text>
+          <text v-if="item.isTop === 1" class="top-tag">置顶</text>
+        </view>
+        <view class="item-title">{{ item.title }}</view>
+        <view class="item-footer">
+          <text class="publish-time">{{ formatTime(item.publishTime) }}</text>
+          <text class="read-count">{{ item.readCount }}次阅读</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-else class="empty-wrap">
+      <text>暂无公告</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { getAnnouncementList, type Announcement } from '../../api/announcement';
+
+const loading = ref(true);
+const announcementList = ref<Announcement[]>([]);
+
+// 格式化时间
+const formatTime = (time: string | null): string => {
+  if (!time) return '';
+  const date = new Date(time);
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+  
+  // 一小时内
+  if (diff < 3600000) {
+    const minutes = Math.floor(diff / 60000);
+    return minutes <= 1 ? '刚刚' : `${minutes}分钟前`;
+  }
+  
+  // 今天
+  if (date.toDateString() === now.toDateString()) {
+    return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
+  }
+  
+  // 昨天
+  const yesterday = new Date(now);
+  yesterday.setDate(yesterday.getDate() - 1);
+  if (date.toDateString() === yesterday.toDateString()) {
+    return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
+  }
+  
+  // 其他
+  return `${date.getMonth() + 1}月${date.getDate()}日`;
+};
+
+// 获取公告列表
+const fetchList = async () => {
+  loading.value = true;
+  try {
+    const list = await getAnnouncementList(20);
+    announcementList.value = list;
+  } catch (error) {
+    console.error('获取公告列表失败:', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 跳转详情
+const goDetail = (item: Announcement) => {
+  uni.navigateTo({
+    url: `/pages/announcementDetail/announcementDetail?id=${item.id}`
+  });
+};
+
+onMounted(() => {
+  fetchList();
+});
+</script>
+
+<style lang="scss" scoped>
+.announcement-list {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding: 20rpx;
+}
+
+.loading-wrap,
+.empty-wrap {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 400rpx;
+  color: #999;
+  font-size: 28rpx;
+}
+
+.list-wrap {
+  .announcement-item {
+    background-color: #fff;
+    border-radius: 16rpx;
+    padding: 24rpx;
+    margin-bottom: 20rpx;
+    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+
+    .item-header {
+      display: flex;
+      align-items: center;
+      margin-bottom: 16rpx;
+
+      .type-tag {
+        font-size: 22rpx;
+        padding: 4rpx 12rpx;
+        border-radius: 8rpx;
+        margin-right: 12rpx;
+
+        &.type-1 {
+          background-color: #e6f4ff;
+          color: #1890ff;
+        }
+
+        &.type-2 {
+          background-color: #f6ffed;
+          color: #52c41a;
+        }
+
+        &.type-3 {
+          background-color: #fffbe6;
+          color: #faad14;
+        }
+
+        &.type-4 {
+          background-color: #f5f5f5;
+          color: #999;
+        }
+      }
+
+      .top-tag {
+        font-size: 22rpx;
+        padding: 4rpx 12rpx;
+        border-radius: 8rpx;
+        background-color: #fff1f0;
+        color: #ff4d4f;
+      }
+    }
+
+    .item-title {
+      font-size: 32rpx;
+      font-weight: 500;
+      color: #333;
+      line-height: 1.4;
+      margin-bottom: 16rpx;
+      display: -webkit-box;
+      -webkit-line-clamp: 2;
+      -webkit-box-orient: vertical;
+      overflow: hidden;
+    }
+
+    .item-footer {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+
+      .publish-time {
+        font-size: 24rpx;
+        color: #999;
+      }
+
+      .read-count {
+        font-size: 24rpx;
+        color: #999;
+      }
+    }
+  }
+}
+</style>

+ 169 - 0
haha-mp/src/pages/announcementDetail/announcementDetail.vue

@@ -0,0 +1,169 @@
+<template>
+  <view class="announcement-detail">
+    <!-- 加载中 -->
+    <view v-if="loading" class="loading-wrap">
+      <text>加载中...</text>
+    </view>
+
+    <!-- 公告详情 -->
+    <view v-else-if="announcement" class="detail-wrap">
+      <!-- 标题 -->
+      <view class="detail-title">{{ announcement.title }}</view>
+
+      <!-- 元信息 -->
+      <view class="detail-meta">
+        <text class="type-tag" :class="'type-' + announcement.type">{{ announcement.typeLabel }}</text>
+        <text class="meta-item">{{ announcement.publisherName || '系统管理员' }}</text>
+        <text class="meta-item">{{ formatTime(announcement.publishTime) }}</text>
+        <text class="meta-item">{{ announcement.readCount }}次阅读</text>
+      </view>
+
+      <!-- 封面图 -->
+      <image
+        v-if="announcement.coverImage"
+        :src="announcement.coverImage"
+        mode="widthFix"
+        class="cover-image"
+      />
+
+      <!-- 内容 -->
+      <view class="detail-content">{{ announcement.content }}</view>
+    </view>
+
+    <!-- 加载失败 -->
+    <view v-else class="error-wrap">
+      <text>公告不存在或已下线</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { getAnnouncementDetail, type Announcement } from '../../api/announcement';
+
+const loading = ref(true);
+const announcement = ref<Announcement | null>(null);
+let announcementId = 0;
+
+// 格式化时间
+const formatTime = (time: string | null): string => {
+  if (!time) return '';
+  const date = new Date(time);
+  const year = date.getFullYear();
+  const month = (date.getMonth() + 1).toString().padStart(2, '0');
+  const day = date.getDate().toString().padStart(2, '0');
+  const hours = date.getHours().toString().padStart(2, '0');
+  const minutes = date.getMinutes().toString().padStart(2, '0');
+  return `${year}-${month}-${day} ${hours}:${minutes}`;
+};
+
+// 获取公告详情
+const fetchDetail = async () => {
+  if (!announcementId) return;
+
+  loading.value = true;
+  try {
+    const detail = await getAnnouncementDetail(announcementId);
+    announcement.value = detail;
+
+    // 设置页面标题
+    uni.setNavigationBarTitle({
+      title: detail.title || '公告详情'
+    });
+  } catch (error) {
+    console.error('获取公告详情失败:', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+onLoad((options?: any) => {
+  if (options?.id) {
+    announcementId = Number(options.id);
+    fetchDetail();
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.announcement-detail {
+  min-height: 100vh;
+  background-color: #fff;
+}
+
+.loading-wrap,
+.error-wrap {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 400rpx;
+  color: #999;
+  font-size: 28rpx;
+}
+
+.detail-wrap {
+  padding: 32rpx;
+
+  .detail-title {
+    font-size: 40rpx;
+    font-weight: 600;
+    color: #333;
+    line-height: 1.4;
+    margin-bottom: 24rpx;
+  }
+
+  .detail-meta {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    margin-bottom: 32rpx;
+
+    .type-tag {
+      font-size: 22rpx;
+      padding: 4rpx 12rpx;
+      border-radius: 8rpx;
+      margin-right: 16rpx;
+
+      &.type-1 {
+        background-color: #e6f4ff;
+        color: #1890ff;
+      }
+
+      &.type-2 {
+        background-color: #f6ffed;
+        color: #52c41a;
+      }
+
+      &.type-3 {
+        background-color: #fffbe6;
+        color: #faad14;
+      }
+
+      &.type-4 {
+        background-color: #f5f5f5;
+        color: #999;
+      }
+    }
+
+    .meta-item {
+      font-size: 24rpx;
+      color: #999;
+      margin-right: 16rpx;
+    }
+  }
+
+  .cover-image {
+    width: 100%;
+    border-radius: 12rpx;
+    margin-bottom: 32rpx;
+  }
+
+  .detail-content {
+    font-size: 30rpx;
+    color: #333;
+    line-height: 1.8;
+    white-space: pre-wrap;
+  }
+}
+</style>

+ 67 - 0
haha-service/src/main/java/com/haha/service/AnnouncementService.java

@@ -0,0 +1,67 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.haha.entity.Announcement;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 系统公告服务接口
+ */
+public interface AnnouncementService extends IService<Announcement> {
+
+    /**
+     * 分页查询公告列表
+     *
+     * @param page     页码
+     * @param pageSize 每页条数
+     * @param params   查询参数
+     * @return 分页结果
+     */
+    IPage<Announcement> getPage(int page, int pageSize, Map<String, Object> params);
+
+    /**
+     * 发布公告
+     *
+     * @param id          公告ID
+     * @param publisherId 发布人ID
+     * @param publisherName 发布人名称
+     * @return 是否成功
+     */
+    boolean publish(Long id, Long publisherId, String publisherName);
+
+    /**
+     * 下线公告
+     *
+     * @param id 公告ID
+     * @return 是否成功
+     */
+    boolean offline(Long id);
+
+    /**
+     * 置顶/取消置顶
+     *
+     * @param id    公告ID
+     * @param isTop 是否置顶
+     * @return 是否成功
+     */
+    boolean setTop(Long id, Integer isTop);
+
+    /**
+     * 获取小程序端公告列表(已发布)
+     *
+     * @param limit 数量限制
+     * @return 公告列表
+     */
+    List<Announcement> getAppList(int limit);
+
+    /**
+     * 获取公告详情(并增加阅读次数)
+     *
+     * @param id 公告ID
+     * @return 公告详情
+     */
+    Announcement getDetail(Long id);
+}

+ 84 - 0
haha-service/src/main/java/com/haha/service/OperationLogService.java

@@ -0,0 +1,84 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.haha.entity.OperationLog;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 操作日志服务接口
+ */
+public interface OperationLogService {
+    
+    /**
+     * 分页查询操作日志
+     * @param page 分页对象
+     * @param module 模块名称
+     * @param username 操作人用户名
+     * @param status 操作状态
+     * @param startTime 开始时间
+     * @param endTime 结束时间
+     * @return 分页结果
+     */
+    Page<OperationLog> pageList(Page<OperationLog> page, String module, String username, 
+                                 Integer status, LocalDateTime startTime, LocalDateTime endTime);
+    
+    /**
+     * 根据ID查询操作日志详情
+     * @param id 日志ID
+     * @return 操作日志
+     */
+    OperationLog getById(Long id);
+    
+    /**
+     * 保存操作日志
+     * @param operationLog 操作日志对象
+     */
+    void save(OperationLog operationLog);
+    
+    /**
+     * 批量删除操作日志
+     * @param ids ID列表
+     */
+    void deleteBatch(List<Long> ids);
+    
+    /**
+     * 清空操作日志
+     */
+    void clearAll();
+    
+    /**
+     * 清空指定时间之前的日志
+     * @param beforeTime 截止时间
+     */
+    void clearBeforeTime(LocalDateTime beforeTime);
+    
+    /**
+     * 统计各模块操作次数
+     * @param startTime 开始时间
+     * @return 统计结果
+     */
+    List<Map<String, Object>> countByModule(LocalDateTime startTime);
+    
+    /**
+     * 统计操作成功失败数
+     * @param startTime 开始时间
+     * @return 统计结果
+     */
+    List<Map<String, Object>> countByStatus(LocalDateTime startTime);
+    
+    /**
+     * 统计每日操作数量
+     * @param startTime 开始时间
+     * @return 统计结果
+     */
+    List<Map<String, Object>> countByDate(LocalDateTime startTime);
+    
+    /**
+     * 获取日志统计数据
+     * @return 统计数据
+     */
+    Map<String, Object> getStatistics();
+}

+ 191 - 0
haha-service/src/main/java/com/haha/service/impl/AnnouncementServiceImpl.java

@@ -0,0 +1,191 @@
+package com.haha.service.impl;
+
+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.entity.Announcement;
+import com.haha.mapper.AnnouncementMapper;
+import com.haha.service.AnnouncementService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 系统公告服务实现类
+ */
+@Slf4j
+@Service
+public class AnnouncementServiceImpl extends ServiceImpl<AnnouncementMapper, Announcement>
+        implements AnnouncementService {
+
+    @Autowired
+    private AnnouncementMapper announcementMapper;
+
+    // 公告类型
+    public static final int TYPE_SYSTEM = 1;      // 系统公告
+    public static final int TYPE_ACTIVITY = 2;    // 活动公告
+    public static final int TYPE_MAINTENANCE = 3; // 维护公告
+    public static final int TYPE_OTHER = 4;       // 其他
+
+    // 公告状态
+    public static final int STATUS_DRAFT = 0;     // 草稿
+    public static final int STATUS_PUBLISHED = 1; // 已发布
+    public static final int STATUS_OFFLINE = 2;   // 已下线
+
+    @Override
+    public IPage<Announcement> getPage(int page, int pageSize, Map<String, Object> params) {
+        LambdaQueryWrapper<Announcement> wrapper = new LambdaQueryWrapper<>();
+
+        // 标题搜索
+        String title = (String) params.get("title");
+        if (StringUtils.hasText(title)) {
+            wrapper.like(Announcement::getTitle, title);
+        }
+
+        // 类型筛选
+        Object typeObj = params.get("type");
+        if (typeObj != null && !typeObj.toString().isEmpty()) {
+            wrapper.eq(Announcement::getType, Integer.valueOf(typeObj.toString()));
+        }
+
+        // 状态筛选
+        Object statusObj = params.get("status");
+        if (statusObj != null && !statusObj.toString().isEmpty()) {
+            wrapper.eq(Announcement::getStatus, Integer.valueOf(statusObj.toString()));
+        }
+
+        // 按置顶和创建时间倒序
+        wrapper.orderByDesc(Announcement::getIsTop)
+               .orderByDesc(Announcement::getCreateTime);
+
+        IPage<Announcement> pageResult = page(new Page<>(page, pageSize), wrapper);
+
+        // 填充展示字段
+        for (Announcement announcement : pageResult.getRecords()) {
+            fillLabels(announcement);
+        }
+
+        return pageResult;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean publish(Long id, Long publisherId, String publisherName) {
+        Announcement announcement = getById(id);
+        if (announcement == null) {
+            return false;
+        }
+
+        announcement.setStatus(STATUS_PUBLISHED);
+        announcement.setPublishTime(LocalDateTime.now());
+        announcement.setPublisherId(publisherId);
+        announcement.setPublisherName(publisherName);
+        announcement.setUpdateTime(LocalDateTime.now());
+
+        return updateById(announcement);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean offline(Long id) {
+        Announcement announcement = getById(id);
+        if (announcement == null) {
+            return false;
+        }
+
+        announcement.setStatus(STATUS_OFFLINE);
+        announcement.setOfflineTime(LocalDateTime.now());
+        announcement.setUpdateTime(LocalDateTime.now());
+
+        return updateById(announcement);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean setTop(Long id, Integer isTop) {
+        Announcement announcement = getById(id);
+        if (announcement == null) {
+            return false;
+        }
+
+        announcement.setIsTop(isTop);
+        announcement.setUpdateTime(LocalDateTime.now());
+
+        return updateById(announcement);
+    }
+
+    @Override
+    public List<Announcement> getAppList(int limit) {
+        LambdaQueryWrapper<Announcement> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Announcement::getStatus, STATUS_PUBLISHED)
+               .orderByDesc(Announcement::getIsTop)
+               .orderByDesc(Announcement::getPublishTime)
+               .last("LIMIT " + limit);
+
+        List<Announcement> list = list(wrapper);
+        for (Announcement announcement : list) {
+            fillLabels(announcement);
+        }
+
+        return list;
+    }
+
+    @Override
+    public Announcement getDetail(Long id) {
+        Announcement announcement = getById(id);
+        if (announcement != null) {
+            // 增加阅读次数
+            announcementMapper.incrementReadCount(id);
+            fillLabels(announcement);
+        }
+        return announcement;
+    }
+
+    /**
+     * 填充展示字段
+     */
+    private void fillLabels(Announcement announcement) {
+        // 公告类型
+        switch (announcement.getType()) {
+            case TYPE_SYSTEM:
+                announcement.setTypeLabel("系统公告");
+                announcement.setTypeColor("primary");
+                break;
+            case TYPE_ACTIVITY:
+                announcement.setTypeLabel("活动公告");
+                announcement.setTypeColor("success");
+                break;
+            case TYPE_MAINTENANCE:
+                announcement.setTypeLabel("维护公告");
+                announcement.setTypeColor("warning");
+                break;
+            default:
+                announcement.setTypeLabel("其他");
+                announcement.setTypeColor("info");
+        }
+
+        // 公告状态
+        switch (announcement.getStatus()) {
+            case STATUS_DRAFT:
+                announcement.setStatusLabel("草稿");
+                announcement.setStatusColor("info");
+                break;
+            case STATUS_PUBLISHED:
+                announcement.setStatusLabel("已发布");
+                announcement.setStatusColor("success");
+                break;
+            case STATUS_OFFLINE:
+                announcement.setStatusLabel("已下线");
+                announcement.setStatusColor("danger");
+                break;
+        }
+    }
+}

+ 154 - 0
haha-service/src/main/java/com/haha/service/impl/OperationLogServiceImpl.java

@@ -0,0 +1,154 @@
+package com.haha.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.haha.entity.OperationLog;
+import com.haha.mapper.OperationLogMapper;
+import com.haha.service.OperationLogService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 操作日志服务实现类
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OperationLogServiceImpl implements OperationLogService {
+    
+    private final OperationLogMapper operationLogMapper;
+    
+    @Override
+    public Page<OperationLog> pageList(Page<OperationLog> page, String module, String username, 
+                                        Integer status, LocalDateTime startTime, LocalDateTime endTime) {
+        LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
+        
+        if (StringUtils.hasText(module)) {
+            wrapper.like(OperationLog::getModule, module);
+        }
+        if (StringUtils.hasText(username)) {
+            wrapper.like(OperationLog::getAdminName, username);
+        }
+        if (status != null) {
+            wrapper.eq(OperationLog::getStatus, status);
+        }
+        if (startTime != null) {
+            wrapper.ge(OperationLog::getCreateTime, startTime);
+        }
+        if (endTime != null) {
+            wrapper.le(OperationLog::getCreateTime, endTime);
+        }
+        
+        wrapper.orderByDesc(OperationLog::getCreateTime);
+        
+        return operationLogMapper.selectPage(page, wrapper);
+    }
+    
+    @Override
+    public OperationLog getById(Long id) {
+        return operationLogMapper.selectById(id);
+    }
+    
+    @Override
+    public void save(OperationLog operationLog) {
+        if (operationLog.getCreateTime() == null) {
+            operationLog.setCreateTime(LocalDateTime.now());
+        }
+        operationLogMapper.insert(operationLog);
+    }
+    
+    @Override
+    @Transactional
+    public void deleteBatch(List<Long> ids) {
+        if (ids != null && !ids.isEmpty()) {
+            operationLogMapper.deleteBatchIds(ids);
+        }
+    }
+    
+    @Override
+    @Transactional
+    public void clearAll() {
+        LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.isNotNull(OperationLog::getId);
+        operationLogMapper.delete(wrapper);
+    }
+    
+    @Override
+    @Transactional
+    public void clearBeforeTime(LocalDateTime beforeTime) {
+        LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.lt(OperationLog::getCreateTime, beforeTime);
+        operationLogMapper.delete(wrapper);
+    }
+    
+    @Override
+    public List<Map<String, Object>> countByModule(LocalDateTime startTime) {
+        return operationLogMapper.countByModule(startTime);
+    }
+    
+    @Override
+    public List<Map<String, Object>> countByStatus(LocalDateTime startTime) {
+        return operationLogMapper.countByStatus(startTime);
+    }
+    
+    @Override
+    public List<Map<String, Object>> countByDate(LocalDateTime startTime) {
+        return operationLogMapper.countByDate(startTime);
+    }
+    
+    @Override
+    public Map<String, Object> getStatistics() {
+        Map<String, Object> result = new HashMap<>();
+        
+        // 今日开始时间
+        LocalDateTime todayStart = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
+        // 近7天开始时间
+        LocalDateTime weekStart = LocalDateTime.of(LocalDate.now().minusDays(7), LocalTime.MIN);
+        
+        // 今日操作数
+        LambdaQueryWrapper<OperationLog> todayWrapper = new LambdaQueryWrapper<>();
+        todayWrapper.ge(OperationLog::getCreateTime, todayStart);
+        Long todayCount = operationLogMapper.selectCount(todayWrapper);
+        result.put("todayCount", todayCount);
+        
+        // 近7天操作数
+        LambdaQueryWrapper<OperationLog> weekWrapper = new LambdaQueryWrapper<>();
+        weekWrapper.ge(OperationLog::getCreateTime, weekStart);
+        Long weekCount = operationLogMapper.selectCount(weekWrapper);
+        result.put("weekCount", weekCount);
+        
+        // 今日成功数
+        LambdaQueryWrapper<OperationLog> successWrapper = new LambdaQueryWrapper<>();
+        successWrapper.ge(OperationLog::getCreateTime, todayStart);
+        successWrapper.eq(OperationLog::getStatus, OperationLog.STATUS_SUCCESS);
+        Long todaySuccess = operationLogMapper.selectCount(successWrapper);
+        result.put("todaySuccess", todaySuccess);
+        
+        // 今日失败数
+        LambdaQueryWrapper<OperationLog> failWrapper = new LambdaQueryWrapper<>();
+        failWrapper.ge(OperationLog::getCreateTime, todayStart);
+        failWrapper.eq(OperationLog::getStatus, OperationLog.STATUS_FAIL);
+        Long todayFail = operationLogMapper.selectCount(failWrapper);
+        result.put("todayFail", todayFail);
+        
+        // 各模块操作统计
+        List<Map<String, Object>> moduleStats = countByModule(weekStart);
+        result.put("moduleStats", moduleStats);
+        
+        // 每日操作趋势
+        List<Map<String, Object>> dailyStats = countByDate(weekStart);
+        result.put("dailyStats", dailyStats);
+        
+        return result;
+    }
+}

+ 8 - 0
pom.xml

@@ -32,6 +32,7 @@
         <satoken.version>1.39.0</satoken.version>
         <fastjson.version>2.0.45</fastjson.version>
         <pagehelper.version>5.3.3</pagehelper.version>
+        <aspectj.version>1.9.22</aspectj.version>
     </properties>
 
     <dependencyManagement>
@@ -85,6 +86,13 @@
                 <version>${pagehelper.version}</version>
             </dependency>
 
+            <!-- AspectJ -->
+            <dependency>
+                <groupId>org.aspectj</groupId>
+                <artifactId>aspectjweaver</artifactId>
+                <version>${aspectj.version}</version>
+            </dependency>
+
             <!-- 内部模块 -->
             <dependency>
                 <groupId>com.haha</groupId>

+ 218 - 0
开发指导.md

@@ -0,0 +1,218 @@
+这是一份面向**CTO、架构师及核心开发人员**的《智能视觉售卖机系统技术开发指导文档》。
+
+该文档不讲业务故事,只讲**代码怎么写、库怎么建、第三方接口怎么接**,以及核心技术难点的解决方案。
+
+---
+
+# 智能视觉售卖机系统 - 技术开发指导书 (Technical Guide)
+
+| 项目属性 | 定义 |
+| :--- | :--- |
+| **后端技术栈** | Java 21, Spring Boot 3.5.9, Maven, Lombok, MyBatis-Plus, Hutool |
+| **前端技术栈** | Uni-app (Vue 3 + TypeScript + Vite + Pinia) |
+| **中间件** | MySQL 8.0, Redis 7.0 (缓存/分布式锁/消息订阅) |
+| **第三方核心** | 哈哈零兽开放平台 API, 微信支付V3 (支付分) |
+| **开发模式** | 前后端分离,RESTful API |
+
+---
+
+## 1. 核心架构与交互时序
+
+### 1.1 系统逻辑拓扑
+*   **网关层**: Nginx/Spring Cloud Gateway (可选),负责SSL终结和路由。
+*   **应用层**: Spring Boot 单体/微服务。
+    *   `Module-User`: C端用户、鉴权、支付分。
+    *   `Module-Device`: 设备状态、指令下发、哈哈API代理。
+    *   `Module-Order`: 订单状态机、回调处理、算费逻辑。
+    *   `Module-Admin`: 商家管理、商品库同步。
+*   **数据层**:
+    *   **MySQL**: 核心业务数据。
+    *   **Redis**:
+        *   `Key: device_status:{sn}` -> 存储设备实时状态(在线/离线/购物中)。
+        *   `Key: lock:door:{sn}` -> 分布式锁,防止并发开门。
+        *   `Key: order:temp:{sn}` -> 临时存储回调数据。
+
+### 1.2 关键交互流程 (购物全链路)
+1.  **开门请求**: 小程序 -> 后端 (校验余额/风控) -> **Redis加锁** -> 调用哈哈API (`apply_open_door`) -> 设备开门。
+2.  **购物中**: 设备上传视频流至哈哈云端 (黑盒)。
+3.  **结算回调**:
+    *   哈哈云端 -> 后端 Webhook (`/api/callback/haha/notify`).
+    *   后端 -> 匹配本地 `out_trade_no`.
+    *   后端 -> 算费 -> **微信支付分扣款**.
+    *   后端 -> WebSocket/Redis PubSub -> 前端提示“支付成功”。
+
+---
+
+## 2. 数据库详细设计 (Schema Design)
+
+### 2.1 核心表结构 (MySQL 8.0)
+
+#### A. 商品表 (t_product)
+视觉柜的核心在于**商品与AI模型的绑定**。
+```sql
+CREATE TABLE `t_product` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `barcode` varchar(32) NOT NULL COMMENT '69码,全局唯一,核心识别键',
+  `name` varchar(128) NOT NULL,
+  `price` decimal(10,2) NOT NULL COMMENT '零售价',
+  `image_url` varchar(512) NOT NULL COMMENT '白底图URL,用于展示及AI同步',
+  `haha_sync_status` tinyint DEFAULT 0 COMMENT '0-未同步 1-已同步 2-同步失败',
+  `haha_model_id` varchar(64) DEFAULT NULL COMMENT '哈哈侧返回的商品ID',
+  `is_deleted` tinyint DEFAULT 0,
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_barcode` (`barcode`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
+```
+
+#### B. 设备表 (t_device)
+```sql
+CREATE TABLE `t_device` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `device_id` varchar(64) NOT NULL COMMENT '哈哈设备SN序列号',
+  `shop_id` bigint NOT NULL COMMENT '归属点位ID',
+  `name` varchar(64) DEFAULT NULL,
+  `auth_token` varchar(128) DEFAULT NULL COMMENT '设备端通信Token(如需)',
+  `status` tinyint DEFAULT 1 COMMENT '0-离线 1-在线 2-购物中 3-故障',
+  `current_inventory_hash` varchar(64) DEFAULT NULL COMMENT '当前库存快照Hash,用于补货比对',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_sn` (`device_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表';
+```
+
+#### C. 订单表 (t_order)
+```sql
+CREATE TABLE `t_order` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `order_no` varchar(32) NOT NULL COMMENT '系统内部订单号',
+  `out_trade_no` varchar(64) NOT NULL COMMENT '请求哈哈开门时的流水号',
+  `haha_order_no` varchar(64) DEFAULT NULL COMMENT '哈哈侧订单号',
+  `user_id` bigint NOT NULL,
+  `device_id` varchar(64) NOT NULL,
+  `total_amount` decimal(10,2) DEFAULT NULL,
+  `pay_status` varchar(20) DEFAULT 'PENDING' COMMENT 'PENDING-识别中, WAIT_PAY-待支付, PAID-已支付, FAIL-失败, REVIEW-待人工',
+  `video_url` varchar(512) DEFAULT NULL COMMENT '购物视频证据',
+  `confidence` decimal(5,4) DEFAULT NULL COMMENT 'AI识别置信度(0.00-1.00)',
+  `items_json` text COMMENT '识别到的原始商品JSON快照',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `pay_time` datetime DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_out_trade` (`out_trade_no`),
+  INDEX `idx_user` (`user_id`),
+  INDEX `idx_status` (`pay_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
+```
+
+---
+
+## 3. 哈哈零兽 API 对接指南 (核心难点)
+
+### 3.1 鉴权工具类封装
+所有请求 Header 必须包含签名。
+*   **AppId**: 平台申请。
+*   **Sign算法**: `MD5(app_id + app_secret + timestamp + body_json)`.
+*   建议封装 `HahaApiClient` 类,统一处理签名和 HTTP POST。
+
+### 3.2 关键接口映射
+
+| 业务动作 | 接口名 (参考) | 请求参数重点 | 回调处理 |
+| :--- | :--- | :--- | :--- |
+| **同步商品** | `goods/add` | `barcode`, `name`, `image`(Url或Base64) | 无 |
+| **申请开门** | `device/applyOpen` | `device_id`, **`out_order_no`** (关键关联键) | 仅返回开门指令发送结果,非订单结果 |
+| **订单结果** | (Webhook) | - | 接收 `out_order_no`, `goods_list`, `video_url` |
+| **设备状态** | (Webhook) | - | 接收 `device_id`, `status` (update Redis) |
+
+### 3.3 回调处理策略 (Spring Event 模式)
+为了解耦 HTTP 回调和业务逻辑:
+1.  Controller 接收哈哈 Webhook JSON。
+2.  校验 `Sign` 确保安全。
+3.  发布 `Spring Event` (如 `HahaOrderEvent`)。
+4.  `@EventListener` 异步处理:
+    *   查询 `t_order` (通过 `out_trade_no`)。
+    *   若订单存在且状态为 `PENDING` -> 计算金额 -> 扣款。
+    *   **幂等性保护**:如果 Redis 中已有该订单处理记录,直接返回 success。
+
+---
+
+## 4. Phase 1: 用户端小程序开发要点
+
+### 4.1 支付分/信用分对接流程
+这是“先享后付”的基础。
+1.  **查询授权**: `GET /api/user/credit_score_status`.
+    *   后端调用微信支付 `v3/payscore/permissions` 查询。
+2.  **申请授权**: 若未授权,前端拉起小程序插件 `plugin-private-wxpay`.
+3.  **创单开门**:
+    *   后端先调用微信支付分“创建订单”接口 (Status: DOING)。
+    *   **锁定成功后**,再调用哈哈 API 开门。
+    *   *原因:防止用户开门后无法扣款。*
+
+### 4.2 轮询与状态反馈
+用户关门后,订单生成有 5-15秒 延迟。
+*   **方案 A (推荐)**: 简单轮询。
+    *   前端每 2秒 调用 `GET /api/order/latest_status?device_id=xxx`.
+    *   后端查询 Redis 或 DB 中的最新订单状态。
+*   **方案 B (进阶)**: WebSocket。
+    *   后端处理完回调后,通过 `SimpMessagingTemplate` 推送 `/topic/user/{userId}`。
+
+---
+
+## 5. Phase 2: PC 运营端开发要点
+
+### 5.1 商品同步队列
+商品图片上传和同步可能耗时。
+*   **实现**: 使用 Spring `ApplicationEventPublisher` 或 RabbitMQ。
+*   **流程**: 管理员保存商品 -> DB 状态设为 `SYNCING` -> 异步线程上传图片给哈哈 -> 获取哈哈 `model_id` -> 更新 DB 为 `SYNCED`。
+
+### 5.2 挂起订单处理 (人工审核)
+针对置信度低的订单:
+*   **后端逻辑**: 接收回调 -> 判断 `confidence < 0.85` -> 状态设为 `REVIEW` (不扣款)。
+*   **PC前端**: 展示 `video_url` (引用哈哈CDN) -> 管理员勾选 `t_product` 中的商品 -> 提交 `/api/admin/order/audit_confirm`。
+*   **后续**: 接口触发算费 -> 调用微信支付分“完结订单”接口扣款。
+
+---
+
+## 6. Phase 3: 商家端小程序开发要点
+
+### 6.1 补货逻辑 (基于快照差异)
+**切记:视觉柜不手动输入数量。**
+1.  **开门**: 调用 `/api/device/maintain_open` (类型: 补货)。
+2.  **回调处理**:
+    *   哈哈返回 `replenish_callback`,包含 `current_stock_list` (当前柜子里的所有东西)。
+    *   **后端逻辑**:
+        *   `Last_Stock` = 查询 DB 中该设备的上次库存。
+        *   `Current_Stock` = 回调数据。
+        *   `Diff` = `Current` - `Last` (计算出的补货量/取货量)。
+        *   更新 DB `t_device_stock` 表为 `Current_Stock`。
+        *   记录 `t_replenish_log` (补货日志)。
+
+---
+
+## 7. 安全与风控实现
+
+### 7.1 防逃单
+*   **问题**: 用户拿货,但微信余额不足,扣款失败。
+*   **处理**:
+    1.  微信返回 `PAY_FAIL`。
+    2.  后端将 `t_order` 状态更为 `DEBT` (欠款)。
+    3.  后端将 `t_user` 标记为 `LOCKED`。
+    4.  **Redis 锁**: `lock:user:forbidden:{userId}`。
+    5.  下次开门前,Filter 拦截器检测到锁,直接拒绝,并返回 `{code: 403, msg: "请先结清欠款"}`。
+
+### 7.2 接口安全
+*   小程序与后端通信:使用 JWT (JSON Web Token),Token 中包含 `userId` 和 `role`。
+*   后端与哈哈通信:严格校验 `Sign`。
+
+---
+
+## 8. 部署建议
+
+*   **JDK**: Dragonwell 21.
+*   **Docker**:
+    *   应用容器: `-m 512M` (JVM优化).
+    *   Redis: 需开启 AOF 持久化,防止宕机丢失设备状态。
+*   **域名**: 必须配置 HTTPS (微信小程序强制要求)。
+*   **内网穿透 (开发期)**: 使用 Ngrok 或 Cpolar 将本地 `7070` 端口暴露为公网域名,填入哈哈后台作为 `Notify Url`。
+
+---
+
+这份指导文档作为开发实施的基准,请开发团队在编码前详细阅读,特别是 **3. 哈哈零兽 API 对接** 和 **6.1 补货逻辑** 部分,这是最容易出错的环节。

+ 236 - 0
需求文档.md

@@ -0,0 +1,236 @@
+这是一个纯粹的、面向业务逻辑和交互细节的**产品需求文档 (PRD)**。
+
+该文档完全剔除了技术实现代码(如Java类、数据库SQL、API参数),专注于**功能定义、业务流程、界面元素、交互逻辑及异常处理**,旨在让UI设计师知道画什么,让前端/后端开发人员知道业务逻辑是什么。
+
+---
+
+## 目录
+1. [产品全局概述](#1-产品全局概述)
+   - [1.1 产品定位](#11-产品定位)
+   - [1.2 核心业务流程](#12-核心业务流程)
+   - [1.3 关键逻辑说明](#13-关键逻辑说明)
+2. [用户端 - 微信小程序 (C端)](#2-用户端---微信小程序-c端)
+   - [2.1 首页](#21-首页)
+   - [2.2 开门鉴权流程](#22-开门鉴权流程)
+   - [2.3 购物进行中](#23-购物进行中)
+   - [2.4 订单结算页](#24-订单结算页)
+   - [2.5 个人中心](#25-个人中心)
+3. [商户运营端 - PC管理后台 (B端)](#3-商户运营端---pc管理后台-b端)
+   - [3.1 仪表盘](#31-仪表盘)
+   - [3.2 商品管理](#32-商品管理)
+   - [3.3 设备管理](#33-设备管理)
+   - [3.4 订单中心 & 异常处理](#34-订单中心--异常处理)
+   - [3.5 营销中心](#35-营销中心)
+   - [3.6 财务对账](#36-财务对账)
+4. [商家运营端 - 微信小程序 (移动运维)](#4-商家运营端---微信小程序-移动运维)
+   - [4.1 工作台首页](#41-工作台首页)
+   - [4.2 智能补货](#42-智能补货)
+   - [4.3 设备控制](#43-设备控制)
+5. [全局异常逻辑与风控需求](#5-全局异常逻辑与风控需求)
+   - [5.1 逃单与坏账处理](#51-逃单与坏账处理)
+   - [5.2 网络异常处理](#52-网络异常处理)
+   - [5.3 门锁安全](#53-门锁安全)
+6. [交互体验细节](#6-交互体验细节)
+
+---
+
+# 智能视觉识别售卖机系统 - 产品需求文档 (PRD)
+
+| 文档属性 | 内容 |
+| :--- | :--- |
+| **项目名称** | 智能视觉识别售卖机系统 |
+| **文档版本** | V1.0 (产品核心版) |
+| **核心模式** | 视觉识别 + 信用免密支付 + 即拿即走 |
+| **底层支撑** | 哈哈零兽 (Haha Lingshou) 智能柜能力 |
+
+---
+
+### 版本控制历史
+| 版本号 | 修改日期 | 修改人 | 修改内容 |
+| :--- | :--- | :--- | :--- |
+| V1.0 | 2026-01-23 | 系统 | 初始版本,完成核心功能需求文档 |
+
+---
+
+### 术语定义
+| 术语 | 解释 |
+| :--- | :--- |
+| 视觉识别 | 基于人工智能技术,通过摄像头拍摄的视频或图像识别商品的技术 |
+| 信用免密支付 | 基于用户的信用评分(如微信支付分、支付宝芝麻分),无需输入密码即可完成支付的方式 |
+| 即拿即走 | 用户扫码开门后,可直接拿走商品,关门后系统自动识别并扣款的购物模式 |
+| 后结算模式 | 先购物后结算的模式,用户先拿走商品,系统后识别并扣款 |
+| 挂起订单 | 当AI识别置信度低于85%时,系统不自动扣款,生成需要人工审核的订单 |
+| 虚拟货架 | 虽然是视觉柜,但仍需配置的商品摆放信息,用于计算满载率 |
+| 快照对比模式 | 运维补货时,系统对比开门前和关门后的商品快照,自动盘点库存的模式 |
+
+---
+
+## 1. 产品全局概述
+
+### 1.1 产品定位
+一款基于人工智能视觉识别技术的智能零售终端管理系统。用户无需逐个扫码商品,只需扫码开门,自由选购,关门后系统自动识别拿走的商品并完成扣款,实现“无感支付”购物体验。
+
+### 1.2 核心业务流程
+1.  **用户**:扫码 -> 授权(微信/支付宝分)-> 开门 -> 拿货 -> 关门 -> 收到扣款通知。
+2.  **运营**:PC端配置商品AI模型 -> 同步设备。
+3.  **运维**:小程序扫码开门 -> 补货 -> 关门 -> 系统自动盘点库存。
+
+### 1.3 关键逻辑说明 (基于视觉柜特性)
+*   **后结算模式**:先购物后结算,必须强依赖“微信支付分”或“支付宝芝麻分”进行风控。
+*   **识别延迟**:用户关门后,云端AI需要5-15秒分析视频,因此订单生成是异步的,前端需有对应的等待交互。
+*   **证据链**:每一笔订单必须关联一段“购物视频”,用于解决用户对AI识别结果的异议。
+
+---
+
+## 2. 用户端 - 微信小程序 (C端)
+
+### 2.1 首页
+*   **页面结构**:
+    *   **顶部**:黄色导航栏,左侧房子图标,中间显示“AI零售柜”标题,右侧三个图标(更多、收起、设置)。
+    *   **中部**:背景为商品图案,中间显示“AI零售柜”大标题。
+    *   **底部(核心)**:黄色大圆形按钮,上面有扫码图标和“扫码开门”文字。
+    *   **底部两侧**:左侧“我的”图标和文字,右侧“退款”图标和文字。
+    *   **底部信息**:显示“微信支付分 | 550分及以上优享”和“客服电话:”文字。
+*   **交互逻辑**:
+    *   点击“扫码开门” -> 调用摄像头。
+    *   解析二维码:若非本机柜二维码,提示“无效二维码”;若是,进入开门鉴权流程。
+
+### 2.2 开门鉴权流程 (核心交互)
+*   **登录判断**:未登录用户强制拉起微信手机号授权登录。
+*   **风控前置检查**:
+    1.  **检查未完成订单**:若用户有一笔订单状态为“识别中”或“待支付”,弹窗提示“您有上一笔订单正在处理,请稍候再试”,禁止开门。
+    2.  **检查信用分**:
+        *   调用支付分接口。若分数 < 550(后台可配),弹窗提示“您的信用分不足,请充值余额使用”。
+        *   若未签约免密支付,跳转至微信/支付宝签约页。
+*   **开门反馈**:
+    *   **开门成功**:手机震动反馈,跳转至【购物进行中】页面。
+    *   **开门失败**:弹窗提示具体原因(如:设备离线、设备被占用、库存不足)。
+
+### 2.3 购物进行中 (状态机页面)
+此页面根据设备状态实时变化,不仅是静态页。
+
+*   **阶段一:选购中 (门已开)**
+    *   **文案**:大字提示“门已开,请选购商品”。
+    *   **操作**:显示“遇到问题?(如门没开)”链接,点击可申请“辅助远程开门”或“报修”。
+    *   **倒计时**:若开门超过60秒未关门,页面变红提示“请尽快关门”。
+*   **阶段二:结算中 (门已关)**
+    *   **触发条件**:用户关上柜门。
+    *   **交互**:展示动态Loading动画,文案“AI正在识别商品,请稍候...”。
+    *   **超时处理**:若等待超过30秒未出结果,页面自动跳转至首页,并顶部通告“订单处理中,稍后将推送账单通知”。
+
+### 2.4 订单结算页
+*   **展示要素**:
+    *   购物清单:商品图、名称、数量、单价。
+    *   优惠明细:优惠券扣减、会员折扣。
+    *   实付金额:突出显示。
+*   **支付逻辑**:
+    *   系统默认自动发起免密扣款。
+    *   **扣款成功**:显示“支付成功”,展示“查看购物视频”按钮。
+    *   **扣款失败**(如余额不足):显示“支付失败”,底部出现“立即支付”按钮,引导用户手动完成支付。
+
+### 2.5 个人中心
+*   **我的订单**:
+    *   列表页显示状态:识别中、已完成、待付款、退款/售后。
+    *   **详情页特色功能**:
+        *   **购物回放**:内嵌视频播放器,播放哈哈零兽API返回的该笔交易监控片段(用户存疑时的核心证据)。
+        *   **申请售后**:针对识别错误的商品,用户可勾选并上传照片申诉。
+*   **钱包/卡包**:
+    *   余额充值:固定金额充值卡片(充100送5)。
+    *   免密管理:查看当前免密支付签约状态,支持解约(需校验无欠款)。
+    *   优惠券:查看可用/失效券。
+
+---
+
+## 3. 商户运营端 - PC管理后台 (B端)
+
+### 3.1 仪表盘 (Dashboard)
+*   **核心数据**:今日销售额、今日订单数、客单价、新增用户数。
+*   **设备概况**:在线设备数、离线设备数、**缺货设备数**(库存<20%)、**异常设备数**(门未关/摄像头故障)。
+*   **待办事项**:待审核的挂起订单、待处理的退款申请。
+
+### 3.2 商品管理 (关联AI模型)
+由于视觉识别依赖图片训练,此模块与传统电商不同。
+*   **商品库**:
+    *   **基础信息**:商品名、分类、成本价、零售价、规格。
+    *   **条码 (Barcode)**:必须录入标准的69码。
+    *   **AI模型图 (关键)**:
+        *   上传要求:必须上传**白底图**或**6个面的包装展开图**。
+        *   **同步状态**:显示该商品在底层AI库的状态(未同步、同步中、已生效、训练失败)。
+        *   *逻辑约束*:未通过“已生效”状态的商品,严禁上架到设备中(因为AI不认识,会导致无法扣款)。
+
+### 3.3 设备管理
+*   **设备档案**:
+    *   绑定:输入机身序列号 (SN) 绑定到商户。
+    *   点位信息:所属区域(省市区)、详细地址(写字楼/学校)、经纬度(用于小程序地图)。
+*   **虚拟货架**:虽然是视觉柜,但需配置“最大容积”或“推荐摆放图”,以便计算满载率。
+*   **二维码下载**:生成设备专属的开门二维码,用于打印张贴。
+
+### 3.4 订单中心 & 异常处理
+*   **订单列表**:包含所有交易流水。
+*   **挂起订单审核 (特色功能)**:
+    *   **场景**:当AI识别置信度低于85%(如遮挡严重),系统不自动扣款,生成“挂起单”。
+    *   **操作界面**:左侧播放购物视频,右侧显示AI识别出的疑似商品列表。
+    *   **人工修正**:管理员根据视频,勾选或修改实际拿走的商品及数量,点击“确认扣款”,系统再向用户发起扣款。
+
+### 3.5 营销中心
+*   **优惠券配置**:满减券、折扣券、单品券。支持设置有效期、发放总量。
+*   **活动规则**:新用户首单立减、充值赠送规则。
+
+### 3.6 财务对账
+*   **资金流水**:每一笔订单的微信/支付宝交易单号、手续费、入账金额。
+*   **异常账单**:统计“已拿货但扣款失败(坏账)”的记录。
+
+---
+
+## 4. 商家运营端 - 微信小程序 (移动运维)
+
+### 4.1 工作台首页
+*   **看板**:展示该运维人员负责区域的设备状态(正常/缺货/离线)。
+*   **快捷入口**:扫码补货、远程开门、故障上报。
+
+### 4.2 智能补货 (快照对比模式)
+*   **场景**:运维人员到达设备前进行补货。
+*   **流程**:
+    1.  **身份识别**:小程序扫码,系统识别为“运维人员”。
+    2.  **开门类型选择**:选择“补货开门”(区别于测试开门或购物开门)。
+    3.  **物理补货**:柜门弹开,运维整理货层,放入商品,关门。
+    4.  **自动盘点**:
+        *   关门后,云端对比“开门前快照”与“关门后快照”。
+        *   **结果确认**:小程序弹出“补货清单确认页”,显示系统识别到的【新增商品及数量】。
+        *   **人工修正**:若AI识别有误(如多识别了一瓶水),运维人员可手动修改数量,点击“确认提交”。系统以运维提交的数据更新本地库存。
+
+### 4.3 设备控制
+*   **远程开门**:用于解决用户扫码无法开门,或门锁卡住的紧急情况。需记录操作日志及原因。
+*   **设备重启**:下发远程指令重启安卓工控机。
+
+---
+
+## 5. 全局异常逻辑与风控需求
+
+### 5.1 逃单与坏账处理
+*   **余额不足**:若用户拿走商品后,免密扣款失败。
+    *   系统自动将该用户标记为“欠费状态”。
+    *   小程序端:用户下次打开小程序,弹窗强制要求补缴欠款,否则无法使用任何功能。
+    *   短信触达:发送欠费催缴短信。
+*   **恶意行为**:若识别到视频中有遮挡摄像头、暴力掰门行为,管理员可在后台将该用户拉入黑名单,永久禁止扫码。
+
+### 5.2 网络异常处理
+*   **断网购物**:
+    *   视觉柜强依赖网络上传视频。若检测到网络信号极差,用户扫码时应直接提示“设备网络不佳,暂停服务”,防止开门后视频无法上传导致无法结算。
+*   **开门超时**:
+    *   扫码后若5秒内未收到开门成功指令,小程序自动停止Loading并提示“连接超时,请重试”。
+
+### 5.3 门锁安全
+*   **门未关报警**:若门锁传感器检测到开启状态超过5分钟,向商家端小程序推送最高级别报警,并发送短信给运维人员。
+
+---
+
+## 6. 交互体验细节 (UX Requirements)
+
+1.  **响应速度**:扫码到开门动作必须在 3秒内 完成(依赖后端优化与Redis缓存)。
+2.  **Loading动效**:在“识别结算中”阶段,必须使用趣味性文案(如“AI正在数数...”)或进度条,缓解用户的焦虑感。
+3.  **视频播放**:订单详情页的视频播放需支持全屏、拖动进度条,且默认静音,点击开启声音。
+
+---
+
+这份文档通过业务视角,详细规定了系统应该“长什么样”以及“怎么工作”,开发团队可依据此文档进行详细的技术方案设计。