Browse Source

fix: 支付分授权后轮询订单状态判断确认/取消,避免取消也开门

wx.openBusinessView 的 success 回调仅表示页面打开成功,不代表用户已确认。
改为 success 后轮询后端查询支付分订单状态(DOING=已确认,CREATED=取消),
确认才开门。新增 GET /payscore/query-out-no/{outOrderNo} 接口。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 weeks ago
parent
commit
ccb7a98ab6
31 changed files with 3604 additions and 15 deletions
  1. 152 0
      .impeccable/design.json
  2. 13 0
      docs/database/add_coupon_receive_type.sql
  3. 3 0
      docs/database/add_device_address.sql
  4. 36 0
      docs/database/add_order_discount_and_paid_amount.sql
  5. 8 0
      docs/database/add_order_type.sql
  6. 7 0
      docs/database/add_order_user_tag_indexes.sql
  7. 7 0
      docs/database/add_payscore_fail_reason.sql
  8. 116 0
      docs/database/alter_snowflake_id.sql
  9. 20 0
      docs/database/create_device_alert_record.sql
  10. 49 0
      docs/database/create_menu.sql
  11. 13 0
      docs/database/migrate_replenisher.sql
  12. 185 0
      docs/database/seed_menu.sql
  13. 1 0
      docs/login.json
  14. 23 0
      docs/restore_role_menu.ps1
  15. 139 0
      docs/test_menu_api.ps1
  16. 69 0
      docs/支付分产品方案及描述.md
  17. 551 0
      docs/支付分场景交互图.html
  18. 599 0
      docs/项目开发报价方案.md
  19. 20 0
      haha-admin-web/CHANGELOG.md
  20. 14 0
      haha-admin/src/main/resources/db/migration/V2__add_order_version_field.sql
  21. 14 0
      haha-admin/src/main/resources/db/migration/V3__add_order_shop_fields.sql
  22. 36 1
      haha-admin/src/main/resources/sql/inventory.sql
  23. 766 0
      haha-admin/src/test/java/com/haha/admin/controller/ReplenisherControllerTest.java
  24. 199 0
      haha-admin/src/test/java/com/haha/admin/controller/ReplenisherLoginControllerTest.java
  25. 396 0
      haha-admin/src/test/java/com/haha/admin/controller/ReplenisherOperationControllerTest.java
  26. 25 0
      haha-miniapp/src/main/java/com/haha/miniapp/controller/PayScoreController.java
  27. 7 0
      haha-mp/src/api/payscore.ts
  28. 67 9
      haha-mp/src/pages/index/index.vue
  29. 51 5
      haha-mp/src/pages/products/products.vue
  30. 1 0
      haha-mp/src/utils/config.ts
  31. 17 0
      skills-lock.json

+ 152 - 0
.impeccable/design.json

@@ -0,0 +1,152 @@
+{
+  "schemaVersion": 2,
+  "generatedAt": "2026-05-11T14:00:00+08:00",
+  "title": "Design System: AI零售柜",
+  "extensions": {
+    "colorMeta": {
+      "primary-yellow": {
+        "role": "primary",
+        "displayName": "Brand Yellow",
+        "canonical": "#FFC107",
+        "tonalRamp": [
+          "#FFF8E1", "#FFECB3", "#FFE082", "#FFD54F",
+          "#FFC107", "#FFB300", "#FFA000", "#FF8F00"
+        ]
+      },
+      "neutral-ink": {
+        "role": "neutral",
+        "displayName": "Ink",
+        "canonical": "#2C2C2C",
+        "tonalRamp": [
+          "#0A0A0A", "#1A1A1A", "#2C2C2C", "#404040",
+          "#555555", "#8C8C8C", "#BDBDBD", "#EEEEEE"
+        ]
+      },
+      "neutral-cream": {
+        "role": "neutral",
+        "displayName": "Cream",
+        "canonical": "#FAFAFA",
+        "tonalRamp": [
+          "#E8E8E8", "#EEEEEE", "#F0F0F0", "#F5F5F5",
+          "#FAFAFA", "#FCFCFC", "#FEFEFE", "#FFFFFF"
+        ]
+      }
+    },
+    "shadows": [
+      { "name": "ambient", "value": "0 2rpx 8rpx rgba(0,0,0,0.04)", "purpose": "Subtle card grounding at rest." },
+      { "name": "low", "value": "0 4rpx 16rpx rgba(0,0,0,0.06)", "purpose": "Default card elevation above cream background." },
+      { "name": "mid", "value": "0 8rpx 32rpx rgba(0,0,0,0.08)", "purpose": "Elevated surfaces: login card, active modal." },
+      { "name": "brand-glow", "value": "0 8rpx 24rpx rgba(255,193,7,0.25)", "purpose": "Scan button halo. The only colored shadow." }
+    ],
+    "motion": [
+      { "name": "ease-fast", "value": "cubic-bezier(0.25, 0.1, 0.25, 1)", "purpose": "Micro-interactions, button state changes (150ms)." },
+      { "name": "ease-standard", "value": "cubic-bezier(0.42, 0, 0.58, 1)", "purpose": "Page transitions, card entrances (300ms)." },
+      { "name": "ease-enter", "value": "cubic-bezier(0, 0, 0.2, 1)", "purpose": "Slide-up entrances for modals and drawers." }
+    ],
+    "breakpoints": []
+  },
+  "components": [
+    {
+      "name": "Scan Button",
+      "kind": "button",
+      "refersTo": "button-primary",
+      "description": "The hero button on the home page. 400rpx circle with Brand Yellow gradient, pulsing ripple, and brand-glow shadow.",
+      "html": "<button class=\"ds-btn-scan\"><span class=\"ds-btn-scan-icon\">📷</span><span class=\"ds-btn-scan-text\">扫码开门</span></button>",
+      "css": ".ds-btn-scan { width: 400rpx; height: 400rpx; border-radius: 50%; background: linear-gradient(135deg, #FFE082, #FFC107); border: none; box-shadow: 0 8rpx 24rpx rgba(255,193,7,0.25); display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; transition: transform 0.3s cubic-bezier(0.25,0.1,0.25,1), box-shadow 0.3s ease; } .ds-btn-scan:active { transform: scale(0.95); box-shadow: 0 4rpx 16rpx rgba(255,193,7,0.3); } .ds-btn-scan-icon { font-size: 80rpx; margin-bottom: 4rpx; } .ds-btn-scan-text { font-size: 40rpx; font-weight: 600; color: #1A1A1A; letter-spacing: 4rpx; }"
+    },
+    {
+      "name": "Primary Action Button",
+      "kind": "button",
+      "refersTo": "button-primary",
+      "description": "Full-width pill button for primary actions like '开门购物' or '立即开通'.",
+      "html": "<button class=\"ds-btn-primary\">开门购物</button>",
+      "css": ".ds-btn-primary { width: 100%; height: 108rpx; background: linear-gradient(135deg, #FFD700, #FFC107, #FFB300); border: none; border-radius: 54rpx; color: #1A1A1A; font-size: 36rpx; font-weight: 600; letter-spacing: 8rpx; box-shadow: 0 12rpx 40rpx rgba(255,193,7,0.35), 0 4rpx 16rpx rgba(255,193,7,0.2); transition: transform 0.3s cubic-bezier(0.25,0.1,0.25,1), box-shadow 0.3s ease; } .ds-btn-primary:active { transform: translateY(-4rpx) scale(1.02); box-shadow: 0 16rpx 48rpx rgba(255,193,7,0.45); }"
+    },
+    {
+      "name": "Ghost Button",
+      "kind": "button",
+      "refersTo": "button-ghost",
+      "description": "Secondary action alongside primary. White fill, light border, used for '返回' and cancel actions.",
+      "html": "<button class=\"ds-btn-ghost\">返回</button>",
+      "css": ".ds-btn-ghost { background: #FFFFFF; border: 2rpx solid #EEEEEE; border-radius: 32rpx; color: #8C8C8C; font-size: 28rpx; font-weight: 500; padding: 16rpx 32rpx; transition: background 0.15s ease; } .ds-btn-ghost:active { background: #FAFAFA; }"
+    },
+    {
+      "name": "Menu Item",
+      "kind": "custom",
+      "refersTo": "menu-item",
+      "description": "List row with icon, label, optional badge, and right-facing chevron. Used in 我的 and settings screens.",
+      "html": "<div class=\"ds-menu-item\"><span class=\"ds-menu-icon\">📋</span><span class=\"ds-menu-label\">我的订单</span><span class=\"ds-menu-arrow\"></span></div>",
+      "css": ".ds-menu-item { display: flex; align-items: center; padding: 24rpx 32rpx; background: #FFFFFF; border-bottom: 1rpx solid #EEEEEE; transition: background 0.15s ease; } .ds-menu-item:active { background: #FAFAFA; } .ds-menu-item:last-child { border-bottom: none; } .ds-menu-icon { width: 40rpx; height: 40rpx; margin-right: 24rpx; } .ds-menu-label { flex: 1; font-size: 28rpx; color: #2C2C2C; } .ds-menu-arrow { width: 16rpx; height: 16rpx; border-top: 2rpx solid #BDBDBD; border-right: 2rpx solid #BDBDBD; transform: rotate(45deg); }"
+    },
+    {
+      "name": "Status Card",
+      "kind": "card",
+      "refersTo": "card-default",
+      "description": "Card with status icon, title, and description. Used in shopping flow for door-open, shopping-done, and error states.",
+      "html": "<div class=\"ds-status-card\"><div class=\"ds-status-icon success\">✓</div><div class=\"ds-status-title\">结算完成</div><div class=\"ds-status-desc\">您已成功购买以下商品</div></div>",
+      "css": ".ds-status-card { background: #FFFFFF; border-radius: 24rpx; padding: 32rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); text-align: center; } .ds-status-icon { width: 56rpx; height: 56rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20rpx; font-size: 32rpx; color: #FFFFFF; font-weight: bold; } .ds-status-icon.success { background: linear-gradient(135deg, #00CC66, #00B359); box-shadow: 0 4rpx 12rpx rgba(0,204,102,0.3); } .ds-status-title { font-size: 34rpx; font-weight: 700; color: #2C2C2C; margin-bottom: 8rpx; } .ds-status-desc { font-size: 24rpx; color: #8C8C8C; }"
+    },
+    {
+      "name": "Product Card",
+      "kind": "card",
+      "refersTo": "card-default",
+      "description": "Product display card with image, name, price, and stock info. Used in cabinet product listing and order detail.",
+      "html": "<div class=\"ds-product-card\"><div class=\"ds-product-img\">🥤</div><div class=\"ds-product-info\"><div class=\"ds-product-name\">可口可乐 330ml</div><div class=\"ds-product-price\">¥3.50</div></div></div>",
+      "css": ".ds-product-card { display: flex; padding: 20rpx 24rpx; background: #FFFFFF; border-bottom: 1rpx solid #F5F5F5; } .ds-product-card:last-child { border-bottom: none; } .ds-product-img { width: 140rpx; height: 140rpx; background: #F5F5F5; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; font-size: 52rpx; margin-right: 20rpx; flex-shrink: 0; } .ds-product-info { flex: 1; display: flex; flex-direction: column; justify-content: center; } .ds-product-name { font-size: 28rpx; color: #2C2C2C; font-weight: 500; line-height: 1.4; margin-bottom: 8rpx; } .ds-product-price { font-size: 32rpx; color: #F44336; font-weight: 700; }"
+    }
+  ],
+  "narrative": {
+    "northStar": "The Yellow Mark",
+    "overview": "AI零售柜 is a consumer mini-program where the yellow accent works like a brand marker — instantly recognizable, like 美团's yellow or 闲鱼's yellow. The surface is clean and white, the yellow appears with purpose: a scan button, a selected state, a price. Its presence signals action; its absence signals rest. The system is built on restraint, not because less is more, but because every element must justify itself against the physical context: a user standing in front of a cabinet under bright office lights.",
+    "keyCharacteristics": [
+      "Yellow as brand marker, not surface decoration",
+      "White-dominant backgrounds with warm tint",
+      "Precision over abundance — every pixel is intentional",
+      "Fast transitions that feel mechanical, not theatrical",
+      "Physical-digital coherence: the phone UI mirrors the cabinet interaction"
+    ],
+    "rules": [
+      {
+        "name": "The One Yellow Rule",
+        "body": "Brand Yellow appears on at most 30-40% of any given screen. It is the brand marker, not the brand blanket. When everything is yellow, nothing is.",
+        "section": "colors"
+      },
+      {
+        "name": "The White Canvas Rule",
+        "body": "White (#FFFFFF) is the default card surface; Cream (#FAFAFA) is the default page background. The difference is subtle but structural — cards float on cream; content that belongs to the page sits directly on cream.",
+        "section": "colors"
+      },
+      {
+        "name": "The Scale Jump Rule",
+        "body": "Adjacent text sizes differ by at least 1.25x. No 28rpx next to 30rpx; the scale skips intentionally: 22 → 24 → 28 → 32 → 36 → 48 → 56.",
+        "section": "typography"
+      },
+      {
+        "name": "The Shadow Has a Job Rule",
+        "body": "Never use a shadow without answering: what does this elevation communicate? If the answer is 'looks nice,' remove it.",
+        "section": "elevation"
+      },
+      {
+        "name": "The Flat-By-Default Rule",
+        "body": "Menu items, list rows, and non-interactive surfaces carry no shadow. Shadow = affordance, not decoration.",
+        "section": "elevation"
+      }
+    ],
+    "dos": [
+      "Use Brand Yellow (#FFC107) exclusively for interactive elements: buttons, selected states, brand marks, and active indicators.",
+      "Use White (#FFFFFF) for cards and Cream (#FAFAFA) for page backgrounds. The distinction creates structure without borders.",
+      "Maintain at least 32rpx of breathing room around primary CTAs.",
+      "Use the Shadow vocabulary intentionally: Ambient for at-rest cards, Brand Glow only for the primary CTA.",
+      "Cap body text at 65-75 characters per line on the widest screen."
+    ],
+    "donts": [
+      "Don't use Brand Yellow as a background fill on text-heavy surfaces. Yellow's job is signaling action, not hosting reading.",
+      "Don't introduce a second accent color. Green, orange, red, and blue are functional signals; they are not decorative accents.",
+      "Don't use nested cards. A card inside a card is always wrong.",
+      "Don't use side-stripe borders (border-left/border-right > 1px) as colored accents.",
+      "Don't use gradient text (background-clip: text). Solid colors only.",
+      "Don't use glassmorphism, decorative blurs, or translucent overlays as a default aesthetic.",
+      "Don't let the interface feel like a discount retail mini-program: no red flash-sale badges, no banner carousels, no cluttered grids of same-sized product cards."
+    ]
+  }
+}

+ 13 - 0
docs/database/add_coupon_receive_type.sql

@@ -0,0 +1,13 @@
+-- 为 t_coupon_template 表添加 receive_type 字段
+-- 发放类型:Collect-主动领取,Release-系统发放
+
+-- 1. 添加 receive_type 字段
+ALTER TABLE t_coupon_template 
+ADD COLUMN receive_type VARCHAR(20) DEFAULT 'Release' COMMENT '发放类型:Collect-主动领取,Release-系统发放' 
+AFTER status;
+
+-- 2. 为现有数据设置默认值
+UPDATE t_coupon_template SET receive_type = 'Release' WHERE receive_type IS NULL;
+
+-- 3. 添加索引优化查询性能
+ALTER TABLE t_coupon_template ADD INDEX idx_receive_type (receive_type);

+ 3 - 0
docs/database/add_device_address.sql

@@ -0,0 +1,3 @@
+-- 为 t_device 表添加 address 字段
+-- 设备地址,从哈哈平台设备列表接口同步获取
+ALTER TABLE t_device ADD COLUMN address VARCHAR(255) DEFAULT NULL COMMENT '设备地址' AFTER door_status;

+ 36 - 0
docs/database/add_order_discount_and_paid_amount.sql

@@ -0,0 +1,36 @@
+-- =====================================================
+-- 订单表添加优惠金额和实付总金额字段
+-- 创建时间: 2026-04-13
+-- 说明: 
+--   - discount_amount: 订单优惠金额(营销活动优惠 + 优惠券优惠)
+--   - paid_amount: 实付总金额 = total_amount - discount_amount
+-- =====================================================
+
+-- 添加优惠金额字段
+ALTER TABLE `t_order` 
+ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0.00 COMMENT '订单优惠金额(营销活动优惠 + 优惠券优惠)' AFTER `total_amount`;
+
+-- 添加实付总金额字段
+ALTER TABLE `t_order` 
+ADD COLUMN `paid_amount` DECIMAL(10, 2) DEFAULT 0.00 COMMENT '实付总金额 = total_amount - discount_amount' AFTER `discount_amount`;
+
+-- 更新已有数据:如果没有优惠金额,则paid_amount = total_amount
+UPDATE `t_order` 
+SET `discount_amount` = 0.00, 
+    `paid_amount` = `total_amount` 
+WHERE `discount_amount` IS NULL OR `paid_amount` IS NULL;
+
+-- 添加索引(可选,用于按优惠金额查询)
+-- ALTER TABLE `t_order` ADD INDEX `idx_discount_amount` (`discount_amount`);
+
+-- 验证
+SELECT 
+    id, 
+    order_no, 
+    total_amount, 
+    discount_amount, 
+    paid_amount,
+    (total_amount - discount_amount) as calculated_paid_amount
+FROM `t_order` 
+ORDER BY id DESC 
+LIMIT 10;

+ 8 - 0
docs/database/add_order_type.sql

@@ -0,0 +1,8 @@
+-- 订单表添加订单类型字段
+-- OUT: 拿出(正常消费)  IN: 放入(异物放入)
+-- 用户端小程序不显示IN类型的订单,仅在运营端显示
+
+ALTER TABLE t_order ADD COLUMN order_type VARCHAR(10) DEFAULT 'OUT' COMMENT '订单类型:OUT-拿出(正常消费),IN-放入(异物放入)';
+
+-- 为历史数据中order_name包含"异物放入"的订单设置类型为IN
+UPDATE t_order SET order_type = 'IN' WHERE order_name LIKE '%异物放入%' OR order_name LIKE '%放入%';

+ 7 - 0
docs/database/add_order_user_tag_indexes.sql

@@ -0,0 +1,7 @@
+-- 用户消费标签功能索引
+-- 用于加速批量查询用户消费次数的SQL
+
+-- 复合索引:覆盖"批量查询用户历史已支付订单数"和"批量查询用户近30天已支付订单数"两个场景
+-- 说明:(user_id, pay_status) 是 (user_id, pay_status, pay_time) 的左前缀,
+--       因此只需创建一个三列索引即可同时满足两种查询
+CREATE INDEX idx_order_user_paid_time ON t_order(user_id, pay_status, pay_time);

+ 7 - 0
docs/database/add_payscore_fail_reason.sql

@@ -0,0 +1,7 @@
+-- 支付分扣款失败原因字段
+-- 用于记录微信支付分扣款失败时 API 返回的错误码和错误信息
+-- 方便运营排查和后续人工处理
+
+ALTER TABLE `t_order`
+ADD COLUMN `pay_score_fail_reason` VARCHAR(500) DEFAULT NULL
+COMMENT '支付分扣款失败原因';

+ 116 - 0
docs/database/alter_snowflake_id.sql

@@ -0,0 +1,116 @@
+-- =====================================================
+-- 雪花算法ID改造 - 数据库表结构变更脚本
+-- 执行时间: 请在低峰期执行
+-- 影响范围: 54张表的主键ID字段
+-- =====================================================
+
+-- 说明: 
+-- 1. 将所有表的id字段从 AUTO_INCREMENT 改为普通 BIGINT
+-- 2. 雪花算法ID由MyBatis-Plus在应用层生成,不再依赖数据库自增
+-- 3. 执行前请务必备份数据库!
+
+-- 核心业务表
+ALTER TABLE t_user MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_order MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_order_item MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_order_goods MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_product MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_device MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_shop MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_device_inventory MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_account MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_fund_log MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 营销相关表
+ALTER TABLE t_marketing_activity MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_coupon_template MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_user_coupon MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_coupon_product MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_coupon_shop MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_coupon_distribute MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_timed_discount_activity MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_timed_discount_device MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_timed_discount_product MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_timed_discount_shop MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_timed_discount_record MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_timed_discount_statistics MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_activity_device MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_activity_product MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_activity_shop MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 库存相关表
+ALTER TABLE t_stock_record MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stock_record_item MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_inventory_log MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stocker MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+-- 注意: t_shop_replenisher 表已被 V2 独立补货员体系取代,即将 DROP
+-- ALTER TABLE t_shop_replenisher MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 日志记录表
+ALTER TABLE t_operation_log MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_door_record MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_sync_log MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_sync_record MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_dict_log MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_price_adjustment_log MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_new_product_apply MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_checkin_record MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 系统管理表
+ALTER TABLE t_admin MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_role MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_data_permission MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_role_permission MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_dict_type MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_dict_data MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 邀请相关表
+ALTER TABLE t_invite_activity MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_invite_record MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_invite_reward MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 统计表
+ALTER TABLE t_marketing_statistics MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stat_category_daily MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stat_device_daily MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stat_product_daily MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stat_shop_daily MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+ALTER TABLE t_stat_user_repurchase MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- 其他表
+ALTER TABLE t_announcement MODIFY COLUMN id BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)';
+
+-- =====================================================
+-- 验证SQL - 执行后可运行以下SQL确认修改成功
+-- =====================================================
+
+-- 检查是否还有AUTO_INCREMENT的id字段(应该返回0条记录)
+SELECT TABLE_NAME, COLUMN_NAME, EXTRA
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = 'haha' 
+  AND COLUMN_NAME = 'id'
+  AND EXTRA = 'auto_increment';
+
+-- 查看所有表的id字段类型(应该都是bigint且没有auto_increment)
+SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, EXTRA, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = 'haha' 
+  AND COLUMN_NAME = 'id'
+ORDER BY TABLE_NAME;
+
+-- =====================================================
+-- 回滚SQL - 如需回滚,可执行以下SQL恢复AUTO_INCREMENT
+-- =====================================================
+
+-- 回滚示例(以t_user为例):
+-- ALTER TABLE t_user MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID';
+
+-- 批量生成回滚SQL:
+-- SELECT CONCAT(
+--     'ALTER TABLE ', TABLE_NAME, 
+--     ' MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''', COLUMN_COMMENT, ''';'
+-- ) AS rollback_sql
+-- FROM information_schema.COLUMNS
+-- WHERE TABLE_SCHEMA = 'haha' 
+--   AND COLUMN_NAME = 'id'
+--   AND EXTRA = '';

+ 20 - 0
docs/database/create_device_alert_record.sql

@@ -0,0 +1,20 @@
+CREATE TABLE t_device_alert_record (
+  id BIGINT NOT NULL COMMENT '主键ID',
+  device_id VARCHAR(32) NOT NULL COMMENT '设备编号',
+  shop_id BIGINT DEFAULT NULL COMMENT '所属门店ID',
+  device_name VARCHAR(64) DEFAULT NULL COMMENT '设备名称',
+  alert_type INT NOT NULL COMMENT '告警类型:1-离线 2-信号弱 3-恢复上线',
+  alert_level INT DEFAULT 1 COMMENT '告警级别:1-普通 2-紧急',
+  alert_content VARCHAR(512) NOT NULL COMMENT '告警内容',
+  signal_value INT DEFAULT NULL COMMENT '信号值',
+  ping_value INT DEFAULT NULL COMMENT '延迟值',
+  notify_status INT DEFAULT 0 COMMENT '通知状态:0-未发送 1-已发送 2-发送失败',
+  notify_channel VARCHAR(32) DEFAULT NULL COMMENT '通知渠道',
+  trigger_source VARCHAR(32) NOT NULL COMMENT '触发来源:CALLBACK/SCHEDULED_SCAN',
+  alert_time DATETIME NOT NULL COMMENT '告警时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  INDEX idx_device_id (device_id),
+  INDEX idx_alert_time (alert_time),
+  INDEX idx_alert_type (alert_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备告警记录表';

+ 49 - 0
docs/database/create_menu.sql

@@ -0,0 +1,49 @@
+-- =====================================================
+-- 菜单表和角色菜单关联表
+-- =====================================================
+
+-- 1. 菜单表
+DROP TABLE IF EXISTS t_menu;
+CREATE TABLE t_menu (
+    id BIGINT PRIMARY KEY COMMENT '菜单ID(雪花算法)',
+    parent_id BIGINT DEFAULT 0 COMMENT '父菜单ID,0表示顶级菜单',
+    menu_type INT DEFAULT 0 COMMENT '菜单类型: 0=菜单 1=iframe 2=外链 3=按钮',
+    title VARCHAR(100) NOT NULL COMMENT '菜单名称',
+    name VARCHAR(100) DEFAULT '' COMMENT '路由名称',
+    path VARCHAR(200) DEFAULT '' COMMENT '路由路径',
+    component VARCHAR(200) DEFAULT '' COMMENT '组件路径',
+    `rank` INT DEFAULT 99 COMMENT '菜单排序',
+    redirect VARCHAR(200) DEFAULT '' COMMENT '路由重定向',
+    icon VARCHAR(100) DEFAULT '' COMMENT '菜单图标',
+    extra_icon VARCHAR(100) DEFAULT '' COMMENT '右侧图标',
+    enter_transition VARCHAR(50) DEFAULT '' COMMENT '进场动画',
+    leave_transition VARCHAR(50) DEFAULT '' COMMENT '离场动画',
+    active_path VARCHAR(200) DEFAULT '' COMMENT '菜单激活路径',
+    auths VARCHAR(200) DEFAULT '' COMMENT '权限标识(按钮级别)',
+    frame_src VARCHAR(500) DEFAULT '' COMMENT 'iframe链接地址',
+    frame_loading TINYINT DEFAULT 1 COMMENT 'iframe是否开启加载动画',
+    keep_alive TINYINT DEFAULT 0 COMMENT '是否缓存页面',
+    hidden_tag TINYINT DEFAULT 0 COMMENT '是否禁止添加到标签页',
+    fixed_tag TINYINT DEFAULT 0 COMMENT '是否固定显示在标签页',
+    show_link TINYINT DEFAULT 1 COMMENT '是否显示该菜单',
+    show_parent TINYINT DEFAULT 0 COMMENT '是否显示父级菜单',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除: 0=正常 1=已删除',
+    KEY idx_parent_id (parent_id),
+    KEY idx_menu_type (menu_type),
+    KEY idx_rank (`rank`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';
+
+-- 2. 角色-菜单关联表
+DROP TABLE IF EXISTS t_role_menu;
+CREATE TABLE t_role_menu (
+    id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法)',
+    role_id BIGINT NOT NULL COMMENT '角色ID',
+    menu_id BIGINT NOT NULL COMMENT '菜单ID',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_role_menu (role_id, menu_id),
+    KEY idx_role_id (role_id),
+    KEY idx_menu_id (menu_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表';
+

+ 13 - 0
docs/database/migrate_replenisher.sql

@@ -0,0 +1,13 @@
+-- =============================================
+-- 补货员系统重构 - 数据迁移脚本
+-- 将现有 t_stocker 数据迁移到 t_replenisher
+-- =============================================
+
+-- 1. 迁移 t_stocker -> t_replenisher
+INSERT INTO `t_replenisher` (`id`, `name`, `phone`, `employee_id`, `status`, `total_tasks`, `last_task_time`, `create_time`, `update_time`)
+SELECT `id`, `name`, `phone`, `employee_id`, `status`, `total_tasks`, `last_task_time`, `create_time`, `update_time`
+FROM `t_stocker`
+WHERE `id` NOT IN (SELECT `id` FROM `t_replenisher`);
+
+-- 3. 清理 V1 遗留表(独立补货员体系已全面取代)
+-- DROP TABLE IF EXISTS `t_shop_replenisher`;

+ 185 - 0
docs/database/seed_menu.sql

@@ -0,0 +1,185 @@
+-- =====================================================
+-- 菜单种子数据 - 匹配前端静态路由结构
+-- =====================================================
+-- 清空旧数据
+DELETE FROM t_role_menu WHERE role_id = 1;
+DELETE FROM t_role_menu WHERE menu_id IN (2001,2002,2003,2004,3001,3002,4001,4002,5001,5002);
+DELETE FROM t_menu WHERE id >= 100 AND id < 1200;
+DELETE FROM t_menu WHERE id IN (2001,2002,2003,2004,3001,3002,4001,4002,5001,5002);
+
+-- =====================================================
+-- 1. 数据概览 (Dashboard) - rank 1
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(100, 0, 0, '数据概览', 'Dashboard', '/dashboard', '', 1, 'ri:dashboard-line', 1, 1, '/dashboard/index', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(101, 100, 0, '数据概览', 'Dashboard', '/dashboard/index', '', 1, 1, 0);
+
+-- =====================================================
+-- 2. 订单管理 (Order) - rank 2
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(200, 0, 0, '订单管理', 'Order', '/order', '', 2, 'ri:file-list-2-line', 1, 1, '/order/list', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(201, 200, 0, '订单管理', 'OrderList', '/order/list', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(202, 200, 0, '退款管理', 'OrderRefund', '/order/refund', '', 2, 1, 0);
+
+-- =====================================================
+-- 3. 运营管理 (Operation) - rank 3
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(300, 0, 0, '运营管理', 'Operation', '/operation', '', 3, 'ri:briefcase-4-line', 1, 1, '/operation/shop', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(301, 300, 0, '门店管理', 'ShopList', '/operation/shop', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(302, 300, 0, '设备管理', 'DeviceList', '/operation/device', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(303, 300, 0, '门店分布地图', 'DistributionMap', '/operation/distribution-map', '', 3, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(304, 300, 0, '补货员管理', 'ReplenisherList', '/operation/replenisher', '', 4, 1, 0);
+
+-- =====================================================
+-- 4. 商品管理 (Product) - rank 4
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(400, 0, 0, '商品管理', 'Product', '/product', '', 4, 'ri:inbox-line', 1, 1, '/product/list', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(401, 400, 0, '商品列表', 'ProductList', '/product/list', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(402, 400, 0, '新品申请', 'NewProductApply', '/product/new-apply', '', 2, 1, 0);
+
+-- =====================================================
+-- 5. 库存管理 (Inventory) - rank 5
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(500, 0, 0, '库存管理', 'Inventory', '/inventory', '', 5, 'ri:stack-line', 1, 1, '/inventory/list', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(501, 500, 0, '库存列表', 'InventoryList', '/inventory/list', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(502, 500, 0, '库存变动', 'InventoryLogs', '/inventory/logs', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(503, 500, 0, '上货记录', 'StockRecords', '/inventory/records', '', 3, 1, 0);
+
+-- =====================================================
+-- 6. 客户管理 (Customer) - rank 6
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(600, 0, 0, '客户管理', 'Customer', '/customer', '', 6, 'ri:user-heart-line', 1, 1, '/customer/list', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(601, 600, 0, '客户列表', 'CustomerList', '/customer/list', '', 1, 1, 0);
+
+-- =====================================================
+-- 7. 分销管理 (Distribution) - rank 6
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(700, 0, 0, '分销管理', 'Distribution', '/distribution', '', 6, 'ep:share', 1, 1, '/distribution/dashboard', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(701, 700, 0, '分销概览', 'DistributionDashboard', '/distribution/dashboard', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(702, 700, 0, '分销员管理', 'DistributionDistributor', '/distribution/distributor', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(703, 700, 0, '推荐记录', 'DistributionReferral', '/distribution/referral', '', 3, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(704, 700, 0, '佣金记录', 'DistributionCommission', '/distribution/commission', '', 4, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(705, 700, 0, '提现管理', 'DistributionWithdrawal', '/distribution/withdrawal', '', 5, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(706, 700, 0, '月度报表', 'DistributionReport', '/distribution/report', '', 6, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(707, 700, 0, '分销配置', 'DistributionConfig', '/distribution/config', '', 7, 1, 0);
+
+-- =====================================================
+-- 8. 营销中心 (Marketing) - rank 7
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(800, 0, 0, '营销中心', 'Marketing', '/marketing', '', 7, 'ri:megaphone-line', 1, 1, '/marketing/activity', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(801, 800, 0, '活动管理', 'MarketingActivity', '/marketing/activity', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(802, 800, 0, '优惠券管理', 'MarketingCoupon', '/marketing/coupon', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(803, 800, 0, '用户优惠券', 'UserCouponList', '/marketing/user-coupon', '', 3, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(804, 800, 0, '定时折扣', 'TimedDiscount', '/marketing/timed-discount', '', 4, 1, 0);
+
+-- 8.5 营销中心 → 数据统计 (三级菜单)
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(810, 800, 0, '数据统计', 'MarketingStatistics', '/marketing/statistics', '', 5, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(811, 810, 0, '统计概览', 'StatisticsOverview', '/marketing/statistics/overview', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(812, 810, 0, '活动统计', 'ActivityStatistics', '/marketing/statistics/activity', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(813, 810, 0, '优惠券统计', 'CouponStatistics', '/marketing/statistics/coupon', '', 3, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(814, 810, 0, '趋势分析', 'TrendAnalysis', '/marketing/statistics/trend', '', 4, 1, 0);
+
+-- =====================================================
+-- 9. 统计报表 (Statistics) - rank 8
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(900, 0, 0, '统计报表', 'Statistics', '/statistics', '', 8, 'ri:bar-chart-grouped-line', 1, 1, '/statistics/overview', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(901, 900, 0, '统计概览', 'StatisticsOverview', '/statistics/overview', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(902, 900, 0, '品类销售统计', 'CategoryStatistics', '/statistics/category', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(903, 900, 0, '门店利润报表', 'ProfitStatistics', '/statistics/profit', '', 3, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(904, 900, 0, '用户复购统计', 'RepurchaseStatistics', '/statistics/repurchase', '', 4, 1, 0);
+
+-- =====================================================
+-- 10. 系统设置 (System) - rank 8
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, `redirect`, keep_alive) VALUES
+(1000, 0, 0, '系统设置', 'System', '/system', '', 8, 'ri:settings-3-line', 1, 1, '/system/admin', 1);
+
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1001, 1000, 0, '管理员管理', 'SystemAdmin', '/system/admin', '', 1, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1002, 1000, 0, '角色管理', 'SystemRole', '/system/role', '', 2, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1003, 1000, 0, '权限管理', 'SystemPermission', '/system/permission', '', 3, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1004, 1000, 0, '公告管理', 'SystemAnnouncement', '/system/announcement', '', 4, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1005, 1000, 0, '操作日志', 'SystemOperationLog', '/system/operation-log', '', 5, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1006, 1000, 0, '数据同步', 'SyncRecords', '/system/sync', '', 6, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1007, 1000, 0, '字典管理', 'SystemDict', '/system/dict', '', 7, 1, 0);
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, show_link, show_parent) VALUES
+(1008, 1000, 0, '菜单管理', 'SystemMenu', '/system/menu', '', 8, 1, 0);
+
+-- =====================================================
+-- 11. 设备层模版管理 (Layer Template) - rank 8
+-- =====================================================
+INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent, keep_alive) VALUES
+(1100, 0, 0, '设备层模版管理', 'LayerTemplate', '/layer-template', '', 8, 'ri:stack-line', 1, 0, 1);
+
+-- =====================================================
+-- 角色菜单关联:admin 角色(role_id=1)拥有所有菜单
+-- =====================================================
+INSERT INTO t_role_menu (id, role_id, menu_id) VALUES
+(90001, 1, 100), (90002, 1, 101),
+(90003, 1, 200), (90004, 1, 201), (90005, 1, 202),
+(90006, 1, 300), (90007, 1, 301), (90008, 1, 302), (90009, 1, 303), (90010, 1, 304),
+(90011, 1, 400), (90012, 1, 401), (90013, 1, 402),
+(90014, 1, 500), (90015, 1, 501), (90016, 1, 502), (90017, 1, 503),
+(90018, 1, 600), (90019, 1, 601),
+(90020, 1, 700), (90021, 1, 701), (90022, 1, 702), (90023, 1, 703), (90024, 1, 704), (90025, 1, 705), (90026, 1, 706), (90027, 1, 707),
+(90028, 1, 800), (90029, 1, 801), (90030, 1, 802), (90031, 1, 803), (90032, 1, 804),
+(90033, 1, 810), (90034, 1, 811), (90035, 1, 812), (90036, 1, 813), (90037, 1, 814),
+(90038, 1, 900), (90039, 1, 901), (90040, 1, 902), (90041, 1, 903), (90042, 1, 904),
+(90043, 1, 1000), (90044, 1, 1001), (90045, 1, 1002), (90046, 1, 1003), (90047, 1, 1004), (90048, 1, 1005), (90049, 1, 1006), (90050, 1, 1007), (90051, 1, 1008),
+(90052, 1, 1100);

+ 1 - 0
docs/login.json

@@ -0,0 +1 @@
+{"username":"admin","password":"admin123"}

+ 23 - 0
docs/restore_role_menu.ps1

@@ -0,0 +1,23 @@
+$base = "http://localhost:7070/admin"
+
+# 1. Login
+$loginBody = Get-Content -Raw "d:\快与慢充电桩\code\haha\docs\login.json"
+$login = Invoke-RestMethod -Uri "$base/login" -Method POST -ContentType "application/json" -Body $loginBody
+$token = $login.data.token
+Write-Host "Login OK, token: $token"
+
+$headers = @{
+    "adminAccessToken" = $token
+    "Content-Type" = "application/json"
+}
+
+# 2. Restore full menu set for admin role (all 11 menus)
+$saveBody = '{"roleId":1,"menuIds":[1001,2001,2002,2003,2004,3001,3002,4001,4002,5001,5002]}'
+$r = Invoke-RestMethod -Uri "$base/menu/save-role-menus" -Method POST -Headers $headers -Body $saveBody
+Write-Host "Restore role menus: code=$($r.code), message=$($r.message)"
+
+# 3. Verify
+$body = '{"id":1}'
+$r = Invoke-RestMethod -Uri "$base/menu/role-menu-ids" -Method POST -Headers $headers -Body $body
+Write-Host "Role 1 menu IDs: $($r.data -join ', ')"
+Write-Host "Count: $($r.data.Count)"

+ 139 - 0
docs/test_menu_api.ps1

@@ -0,0 +1,139 @@
+$base = "http://localhost:7070/admin"
+$headers = @{
+    "adminAccessToken" = ""
+    "Content-Type" = "application/json"
+}
+
+Write-Host "Logging in first..."
+$loginBody = Get-Content -Raw "d:\快与慢充电桩\code\haha\docs\login.json"
+$login = Invoke-RestMethod -Uri "$base/login" -Method POST -ContentType "application/json" -Body $loginBody
+Write-Host "login code: $($login.code), message: $($login.message)"
+$token = $login.data.token
+$headers["adminAccessToken"] = $token
+Write-Host "Got token: $token"
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "1. Test GET /menu/routes (user menu tree)"
+Write-Host "========================================"
+$r = Invoke-RestMethod -Uri "$base/menu/routes" -Method GET -Headers $headers
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host "data count: $($r.data.Count)"
+if ($r.data.Count -gt 0) {
+    Write-Host "first item path: $($r.data[0].path)"
+    Write-Host "first item meta title: $($r.data[0].meta.title)"
+}
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "2. Test POST /menu (all menus)"
+Write-Host "========================================"
+$body = '{}'
+$r = Invoke-RestMethod -Uri "$base/menu" -Method POST -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host "total menus: $($r.data.Count)"
+if ($r.data.Count -gt 0) {
+    Write-Host "first menu: id=$($r.data[0].id), title=$($r.data[0].title)"
+}
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "3. Test POST /menu (search by title)"
+Write-Host "========================================"
+$body = '{"title":"仪表"}'
+$r = Invoke-RestMethod -Uri "$base/menu" -Method POST -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host "found menus: $($r.data.Count)"
+if ($r.data.Count -gt 0) {
+    Write-Host "first: id=$($r.data[0].id), title=$($r.data[0].title)"
+}
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "4. Test POST /menu/role-menu (menus for role assignment)"
+Write-Host "========================================"
+$body = '{}'
+$r = Invoke-RestMethod -Uri "$base/menu/role-menu" -Method POST -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host "total menus: $($r.data.Count)"
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "5. Test POST /menu/role-menu-ids (menu IDs for role 1)"
+Write-Host "========================================"
+$body = '{"id":1}'
+$r = Invoke-RestMethod -Uri "$base/menu/role-menu-ids" -Method POST -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host "menu IDs for role 1: $($r.data)"
+Write-Host "count: $($r.data.Count)"
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "6. Test POST /menu/create (create new menu)"
+Write-Host "========================================"
+$body = @'
+{
+    "parentId": 3001,
+    "menuType": 0,
+    "title": "测试菜单",
+    "name": "TestMenu",
+    "path": "/system/test",
+    "component": "",
+    "rank": 99,
+    "icon": "ri:test-tube-line",
+    "showLink": true,
+    "showParent": false,
+    "keepAlive": false
+}
+'@
+$r = Invoke-RestMethod -Uri "$base/menu/create" -Method POST -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+if ($r.data) {
+    Write-Host "created id: $($r.data.id), title: $($r.data.title)"
+    $newId = $r.data.id
+}
+
+Write-Host ""
+Write-Host "========================================"
+Write-Host "7. Test PUT /menu/{id} (update menu)"
+Write-Host "========================================"
+$body = @'
+{
+    "parentId": 3001,
+    "menuType": 0,
+    "title": "测试菜单-已更新",
+    "name": "TestMenuUpdated",
+    "path": "/system/test-updated",
+    "component": "",
+    "rank": 88,
+    "icon": "ri:test-tube-fill",
+    "showLink": true,
+    "showParent": false,
+    "keepAlive": false
+}
+'@
+$r = Invoke-RestMethod -Uri "$base/menu/$newId" -Method PUT -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+if ($r.data) {
+    Write-Host "updated title: $($r.data.title)"
+}
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "8. Test DELETE /menu/{id} (delete menu)"
+Write-Host "========================================"
+$r = Invoke-RestMethod -Uri "$base/menu/$newId" -Method DELETE -Headers $headers
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host ""
+
+Write-Host "========================================"
+Write-Host "9. Test POST /menu/save-role-menus (save role menu)"
+Write-Host "========================================"
+$body = @"
+{"roleId": 1, "menuIds": [1001, 2001, 3001, 4001, 5001]}
+"@
+$r = Invoke-RestMethod -Uri "$base/menu/save-role-menus" -Method POST -Headers $headers -Body $body
+Write-Host "code: $($r.code), message: $($r.message)"
+Write-Host ""
+
+Write-Host "=== ALL TESTS COMPLETE ==="

+ 69 - 0
docs/支付分产品方案及描述.md

@@ -0,0 +1,69 @@
+# 使用支付分的产品方案及描述
+
+## 一、产品概述
+
+**AI智能零售柜**是一款基于AI视觉识别技术的无人零售终端设备,为用户提供"扫码开门 → 即拿即走 → 自动扣款"的无感购物体验。产品依托微信支付分能力,实现免押金、先享后付的信用消费模式。
+
+## 二、支付分使用场景
+
+**接入场景**:免押金租借/即时零售(AI智能零售柜)
+
+用户通过微信小程序扫描柜体二维码,系统校验微信支付分(需550分及以上)后远程开柜门,用户自由选取商品、关门后AI自动识别所取商品并生成订单,通过微信支付分完成自动扣款。
+
+## 三、产品方案描述
+
+### 3.1 业务流程
+
+```
+用户扫码 → 支付分校验 → 开柜门 → 自由选购 → 关门 → AI识别 → 自动扣款 → 订单完成
+```
+
+1. **扫码开门**:用户打开微信扫一扫,扫描柜体机身二维码进入小程序,系统自动校验用户微信支付分是否达到550分。未开通支付分的用户引导前往开通页面完成开通。
+
+2. **查看商品/开门**:扫码成功后,用户可选择"查看柜内商品"预览柜内各层商品及价格,或选择"直接开门"立即开启购物。选择"查看柜内商品"后仍可点击"开门购物"按钮开门。
+
+3. **开通微信支付分**:未开通支付分的用户进入开通引导页,了解支付分的核心权益(先享后付、免密支付、快速开门),通过微信官方API调起支付分开通流程,完成身份验证后即可开通。
+
+4. **选购商品**:柜门打开后,用户可在60秒内自由选取商品,页面实时显示倒计时。用户选取完毕后关闭柜门。
+
+5. **AI识别与订单生成**:关门后,柜内摄像头拍摄商品图像,AI视觉识别系统自动识别用户所取商品的种类和数量,生成订单。
+
+6. **自动扣款**:订单生成后,系统通过微信支付分接口自动完成扣款。同时系统自动匹配用户可用优惠券,优先使用即将过期的优惠券进行抵扣。
+
+7. **订单完成**:扣款成功后,用户可在小程序内查看订单详情,包括商品明细、原价、优惠金额、实付金额等。支持7天内申请退款。
+
+### 3.2 支付分使用方式
+
+- **免押金**:用户无需支付任何押金,凭微信支付分(≥550分)即可开门购物
+- **先享后付**:用户先取走商品,关门后系统自动从微信支付账户扣款
+- **免密支付**:用户开通支付分并授权后,小额订单自动完成扣款,无需每次输入密码
+- **优惠券叠加**:系统自动匹配最优优惠券,在扣款时自动抵扣
+
+### 3.3 计费规则
+
+- **押金**:无(基于微信支付分信用免押)
+- **计费标准**:按商品零售价计费,不同商品价格不同
+- **优惠机制**:支持优惠券抵扣(满减券、折扣券、单品券)、新用户首单优惠、时段折扣等
+- **退款规定**:订单完成后7天内可申请退款,退款金额原路返回
+
+### 3.4 接入渠道
+
+- **渠道类型**:微信小程序
+- **小程序名称**:AI零售柜
+- **小程序AppID**:wxb1cbe4678e0175f2
+
+### 3.5 技术方案
+
+支付分对接采用微信支付分API V3接口:
+
+1. **支付分授权**:调用 `wx.openBusinessView` 调起支付分授权页,用户确认授权后获取 `pre_authorization_token`
+2. **创单结单**:用户关门后,后端调用支付分创单接口创建支付分订单,AI识别完成后调用完结接口确认金额并扣款
+3. **免密扣款**:已授权用户后续购物可直接使用授权token创建订单并完成扣款,无需重复授权
+4. **退款**:支持通过支付分退款接口完成退款
+
+### 3.6 风控说明
+
+- 微信支付分550分门槛确保用户具备基本信用
+- AI视觉识别 + 视频录制留存,提供订单追溯依据
+- 单设备实时状态监控,异常开门/关门可远程干预
+- 7天退款期,超期自动关闭退款入口

+ 551 - 0
docs/支付分场景交互图.html

@@ -0,0 +1,551 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>AI智能零售柜 - 微信支付分场景交互流程</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
+  background: #f0f0f0;
+  display: flex;
+  justify-content: center;
+  padding: 40px 20px;
+}
+.flow-container {
+  max-width: 1200px;
+  width: 100%;
+  background: #fff;
+  border-radius: 16px;
+  padding: 48px 40px;
+  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
+}
+.flow-title {
+  text-align: center;
+  font-size: 28px;
+  font-weight: 700;
+  color: #1a1a1a;
+  margin-bottom: 8px;
+}
+.flow-subtitle {
+  text-align: center;
+  font-size: 14px;
+  color: #999;
+  margin-bottom: 40px;
+}
+
+/* 流程图 */
+.pipeline {
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  gap: 0;
+  flex-wrap: wrap;
+  margin-bottom: 48px;
+}
+.step-group {
+  display: flex;
+  align-items: flex-start;
+}
+.step-card {
+  width: 185px;
+  background: #fafafa;
+  border-radius: 12px;
+  border: 2px solid #e8e8e8;
+  padding: 16px 12px;
+  text-align: center;
+  position: relative;
+  transition: all 0.2s;
+}
+.step-card.current {
+  border-color: #07C160;
+  background: #f0fdf4;
+  box-shadow: 0 0 0 4px rgba(7,193,96,0.08);
+}
+.step-num {
+  display: inline-block;
+  width: 28px;
+  height: 28px;
+  line-height: 28px;
+  border-radius: 50%;
+  background: #07C160;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 700;
+  margin-bottom: 10px;
+}
+.step-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #1a1a1a;
+  margin-bottom: 6px;
+}
+.step-desc {
+  font-size: 11px;
+  color: #999;
+  line-height: 1.5;
+}
+.step-arrow {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  flex-shrink: 0;
+  color: #ccc;
+  font-size: 20px;
+  padding-top: 30px;
+}
+
+/* 手机预览区 */
+.phones-row {
+  display: flex;
+  justify-content: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.phone-frame {
+  width: 280px;
+  background: #fff;
+  border-radius: 28px;
+  border: 3px solid #e0e0e0;
+  overflow: hidden;
+  box-shadow: 0 8px 32px rgba(0,0,0,0.1);
+}
+.phone-statusbar {
+  height: 32px;
+  background: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 20px;
+  font-size: 11px;
+  color: #333;
+  font-weight: 600;
+}
+.phone-nav {
+  height: 40px;
+  background: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 14px;
+  font-weight: 600;
+  color: #333;
+  border-bottom: 1px solid #f0f0f0;
+  position: relative;
+}
+.phone-nav .back {
+  position: absolute;
+  left: 14px;
+  font-size: 14px;
+  color: #333;
+}
+.phone-body {
+  height: 460px;
+  overflow-y: auto;
+  background: #f7f7f7;
+  padding: 16px;
+}
+.phone-label {
+  text-align: center;
+  font-size: 12px;
+  color: #888;
+  margin-top: 14px;
+  margin-bottom: 4px;
+  font-weight: 500;
+}
+
+/* 各页面内部样式 */
+.scan-btn {
+  width: 120px;
+  height: 120px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FFD54F, #FFC107);
+  margin: 40px auto 20px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8px 24px rgba(255,193,7,0.4);
+}
+.scan-btn .icon { font-size: 40px; margin-bottom: 4px; }
+.scan-btn .text { font-size: 16px; font-weight: 700; color: #1a1a1a; }
+.brand { text-align: center; padding: 20px 0 10px; }
+.brand .name { font-size: 22px; font-weight: 300; color: #333; letter-spacing: 4px; }
+.brand .slogan { font-size: 11px; color: #aaa; margin-top: 4px; }
+.quick-actions { display: flex; justify-content: center; gap: 24px; margin: 20px 0; }
+.qa-item { text-align: center; }
+.qa-item .icon-box { width: 48px; height: 48px; background: #fff; border-radius: 10px;
+  display: flex; align-items: center; justify-content: center; font-size: 22px;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.06); margin-bottom: 4px; }
+.qa-item .label { font-size: 11px; color: #666; }
+.info-tip { background: #fff; border-radius: 10px; padding: 12px; margin-top: 20px;
+  text-align: center; font-size: 11px; color: #999; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
+.info-tip .ps-icon { color: #07C160; font-weight: 600; }
+
+/* 商品陈列页 */
+.product-header { background: #fff; border-radius: 10px; padding: 12px; margin-bottom: 12px;
+  display: flex; align-items: center; gap: 8px; font-size: 12px; }
+.floor-card { background: #fff; border-radius: 10px; margin-bottom: 10px; overflow: hidden;
+  box-shadow: 0 1px 6px rgba(0,0,0,0.05); }
+.floor-header { display: flex; align-items: center; padding: 10px 12px;
+  border-bottom: 1px solid #f5f5f5; font-size: 12px; }
+.floor-badge { width: 22px; height: 22px; line-height: 22px; text-align: center;
+  background: #FFD700; border-radius: 6px; font-size: 12px; font-weight: 700; margin-right: 8px; }
+.goods-item { display: flex; padding: 10px 12px; align-items: center; }
+.goods-img { width: 52px; height: 52px; background: #f5f5f5; border-radius: 8px;
+  display: flex; align-items: center; justify-content: center; font-size: 26px; margin-right: 10px; }
+.goods-info { flex: 1; }
+.goods-name { font-size: 13px; color: #333; font-weight: 500; }
+.goods-price { font-size: 16px; color: #ff4d4f; font-weight: 700; }
+.bottom-bar { display: flex; gap: 8px; padding: 10px 12px; background: #fff;
+  border-top: 1px solid #f0f0f0; margin: -16px; margin-top: 8px; }
+.btn-back { flex:1; text-align:center; padding:10px; background:#f0f0f0; border-radius:22px;
+  font-size:13px; color:#666; font-weight:600; }
+.btn-open { flex:2; text-align:center; padding:10px; background:linear-gradient(135deg,#FFD700,#FFC107);
+  border-radius:22px; font-size:13px; color:#333; font-weight:700;
+  box-shadow:0 4px 12px rgba(255,215,0,0.4); }
+
+/* 开门购物页 */
+.countdown-big { font-size: 62px; font-weight: 700; text-align: center; margin: 16px 0; }
+.status-msg { text-align: center; font-size: 14px; color: #333; font-weight: 600; }
+.status-sub { text-align: center; font-size: 12px; color: #999; margin-top: 4px; }
+.order-items { background: #fff; border-radius: 10px; padding: 14px; margin-top: 12px; }
+.order-item { display: flex; justify-content: space-between; padding: 8px 0; font-size: 12px; }
+.order-total { display: flex; justify-content: space-between; padding: 10px 0;
+  border-top: 2px solid #f0f0f0; margin-top: 6px; font-weight: 700; font-size: 14px; }
+.pay-badge { display: inline-block; background: #07C160; color: #fff; padding: 3px 10px;
+  border-radius: 10px; font-size: 10px; margin-left: 4px; }
+
+/* 支付分开通页 */
+.ps-score { text-align: center; padding: 16px 0; }
+.ps-score .num { font-size: 64px; font-weight: 700; color: #07C160; }
+.ps-score .unit { font-size: 16px; color: #999; }
+.ps-badge { display: inline-block; background: #fa5555; color: #fff; padding: 4px 12px;
+  border-radius: 12px; font-size: 11px; }
+.benefits { display: flex; gap: 8px; margin: 14px 0; }
+.benefit { flex:1; background:#fff; border-radius:10px; padding:12px 8px; text-align:center;
+  box-shadow:0 1px 6px rgba(0,0,0,0.05); }
+.benefit .icon { font-size: 28px; margin-bottom: 4px; }
+.benefit .title { font-size: 11px; font-weight: 600; color: #333; }
+.ps-enable-btn { width: 100%; padding: 12px; background: #07C160; border-radius: 22px;
+  text-align: center; color: #fff; font-size: 14px; font-weight: 600; margin-top: 8px; }
+
+/* 订单完成页 */
+.success-icon { text-align: center; padding: 16px 0; font-size: 48px; }
+
+.footer-note {
+  text-align: center;
+  font-size: 11px;
+  color: #aaa;
+  margin-top: 40px;
+}
+</style>
+</head>
+<body>
+
+<div class="flow-container">
+  <div class="flow-title">AI智能零售柜 · 微信支付分场景交互流程</div>
+  <div class="flow-subtitle">完整用户购物路径 — 从扫码开门到自动扣款完成</div>
+
+  <!-- 流程步骤条 -->
+  <div class="pipeline">
+    <div class="step-group">
+      <div class="step-card">
+        <div class="step-num">1</div>
+        <div class="step-title">扫码进入小程序</div>
+        <div class="step-desc">用户扫描柜体二维码<br>进入AI零售柜小程序</div>
+      </div>
+      <div class="step-arrow">→</div>
+    </div>
+    <div class="step-group">
+      <div class="step-card">
+        <div class="step-num">2</div>
+        <div class="step-title">商品展示页</div>
+        <div class="step-desc">查看柜内商品陈列<br>含价格、库存信息</div>
+      </div>
+      <div class="step-arrow">→</div>
+    </div>
+    <div class="step-group">
+      <div class="step-card">
+        <div class="step-num">3</div>
+        <div class="step-title">支付分开通</div>
+        <div class="step-desc">检查微信支付分<br>未开通引导授权</div>
+      </div>
+      <div class="step-arrow">→</div>
+    </div>
+    <div class="step-group">
+      <div class="step-card">
+        <div class="step-num">4</div>
+        <div class="step-title">开门选购</div>
+        <div class="step-desc">远程开柜门<br>自由选购商品</div>
+      </div>
+      <div class="step-arrow">→</div>
+    </div>
+    <div class="step-group">
+      <div class="step-card current">
+        <div class="step-num">5</div>
+        <div class="step-title">AI识别扣款</div>
+        <div class="step-desc">关门自动识别商品<br>支付分自动扣款</div>
+      </div>
+      <div class="step-arrow">→</div>
+    </div>
+    <div class="step-group">
+      <div class="step-card">
+        <div class="step-num">6</div>
+        <div class="step-title">订单完成</div>
+        <div class="step-desc">查看订单详情<br>支持7天无理由退款</div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 手机页面展示 -->
+  <div class="phones-row">
+
+    <!-- 页面1: 首页 / 扫码入口 -->
+    <div>
+      <div class="phone-frame">
+        <div class="phone-statusbar">9:41 &nbsp;&nbsp; 100%</div>
+        <div class="phone-nav">AI零售柜</div>
+        <div class="phone-body">
+          <div class="brand">
+            <div class="name">AI零售柜</div>
+            <div class="slogan">智能视觉 · 即拿即走</div>
+          </div>
+          <div class="scan-btn">
+            <div class="icon">📷</div>
+            <div class="text">扫码开门</div>
+          </div>
+          <div class="quick-actions">
+            <div class="qa-item">
+              <div class="icon-box">👤</div>
+              <div class="label">我的</div>
+            </div>
+            <div class="qa-item">
+              <div class="icon-box">📋</div>
+              <div class="label">订单</div>
+            </div>
+            <div class="qa-item">
+              <div class="icon-box">🎫</div>
+              <div class="label">优惠券</div>
+            </div>
+          </div>
+          <div class="info-tip">
+            <span class="ps-icon">◆ 微信支付分</span> 550分及以上优享
+          </div>
+          <div class="info-tip" style="margin-top:8px;">
+            💬 在线客服 &nbsp; 400-0755-315
+          </div>
+        </div>
+      </div>
+      <div class="phone-label">① 首页 - 扫码入口</div>
+    </div>
+
+    <!-- 页面2: 商品陈列页 -->
+    <div>
+      <div class="phone-frame">
+        <div class="phone-statusbar">9:42 &nbsp;&nbsp; 100%</div>
+        <div class="phone-nav"><span class="back">←</span>柜内商品</div>
+        <div class="phone-body">
+          <div class="product-header">
+            📱 单门柜 · 共3层商品
+          </div>
+          <div class="floor-card">
+            <div class="floor-header">
+              <div class="floor-badge">1</div>
+              <span style="font-weight:600;">第1层</span>
+              <span style="color:#999;margin-left:auto;">3件</span>
+            </div>
+            <div class="goods-item">
+              <div class="goods-img">🥤</div>
+              <div class="goods-info">
+                <div class="goods-name">可口可乐 330ml</div>
+                <div class="goods-price">¥3.50</div>
+              </div>
+            </div>
+            <div class="goods-item">
+              <div class="goods-img">🧃</div>
+              <div class="goods-info">
+                <div class="goods-name">农夫山泉 550ml</div>
+                <div class="goods-price">¥2.00</div>
+              </div>
+            </div>
+          </div>
+          <div class="floor-card">
+            <div class="floor-header">
+              <div class="floor-badge">2</div>
+              <span style="font-weight:600;">第2层</span>
+              <span style="color:#999;margin-left:auto;">2件</span>
+            </div>
+            <div class="goods-item">
+              <div class="goods-img">🍪</div>
+              <div class="goods-info">
+                <div class="goods-name">奥利奥饼干 97g</div>
+                <div class="goods-price">¥8.90</div>
+              </div>
+            </div>
+          </div>
+          <div class="bottom-bar">
+            <div class="btn-back">返回</div>
+            <div class="btn-open">开门购物</div>
+          </div>
+        </div>
+      </div>
+      <div class="phone-label">② 商品展示页 - 含价格信息</div>
+    </div>
+
+    <!-- 页面3: 支付分开通页 -->
+    <div>
+      <div class="phone-frame">
+        <div class="phone-statusbar">9:41 &nbsp;&nbsp; 100%</div>
+        <div class="phone-nav" style="color:#07C160;">微信支付分</div>
+        <div class="phone-body">
+          <div class="ps-score">
+            <div style="margin-bottom: 8px;"><span class="ps-badge">必需条件</span></div>
+            <div class="num">550</div>
+            <div class="unit">分及以上</div>
+            <div style="font-size: 11px; color: #999; margin-top: 8px;">
+              ℹ 分数越高,可享受的信用服务越多
+            </div>
+          </div>
+          <div style="font-size: 13px; font-weight: 600; margin-bottom: 8px;">核心权益</div>
+          <div class="benefits">
+            <div class="benefit">
+              <div class="icon">💳</div>
+              <div class="title">先享后付</div>
+              <div style="font-size:10px;color:#aaa;">开门后付款</div>
+            </div>
+            <div class="benefit">
+              <div class="icon">🛡️</div>
+              <div class="title">免密支付</div>
+              <div style="font-size:10px;color:#aaa;">小额自动扣款</div>
+            </div>
+            <div class="benefit">
+              <div class="icon">⚡</div>
+              <div class="title">免押金</div>
+              <div style="font-size:10px;color:#aaa;">无需预付押金</div>
+            </div>
+          </div>
+          <div style="background:#fff;border-radius:10px;padding:12px;font-size:11px;color:#666;line-height:1.8;">
+            <div style="font-weight:600;color:#333;margin-bottom:6px;">什么是微信支付分?</div>
+            微信支付分是微信支付基于历史行为作出的综合评分。开通后可享受先享后付、免押金等便捷服务。
+          </div>
+          <div class="ps-enable-btn">立即开通</div>
+          <div style="text-align:center;margin-top:8px;font-size:10px;color:#aaa;">
+            如有疑问,请联系客服 400-0755-315
+          </div>
+        </div>
+      </div>
+      <div class="phone-label">③ 提交订单页 - 支付分开通</div>
+    </div>
+
+    <!-- 页面4: 购物中 / AI识别扣款 -->
+    <div>
+      <div class="phone-frame">
+        <div class="phone-statusbar">9:43 &nbsp;&nbsp; 100%</div>
+        <div class="phone-nav">购物进行中</div>
+        <div class="phone-body">
+          <div style="text-align:center;padding:20px 0 8px;">
+            <div style="font-size:48px;">🚪</div>
+          </div>
+          <div class="status-msg">门已开,请选购商品</div>
+          <div class="status-sub">请在60秒内完成选购并关门</div>
+          <div class="countdown-big" style="color:#333;">45<span style="font-size:20px;color:#999;">秒</span></div>
+
+          <div style="border-top:2px dashed #e0e0e0;margin:16px 0;position:relative;">
+            <span style="position:absolute;top:-10px;left:50%;transform:translateX(-50%);
+              background:#f7f7f7;padding:0 12px;font-size:10px;color:#aaa;">关门后</span>
+          </div>
+
+          <div style="background:#fff;border-radius:10px;padding:14px;text-align:center;">
+            <div style="font-size:36px;margin-bottom:4px;">🤖</div>
+            <div style="font-size:13px;font-weight:600;">AI智能识别中...</div>
+            <div style="font-size:11px;color:#999;">正在识别您选购的商品</div>
+          </div>
+
+          <div class="order-items" style="margin-top:12px;">
+            <div style="font-size:12px;font-weight:600;margin-bottom:8px;">识别结果</div>
+            <div class="order-item">
+              <span>可口可乐 ×1</span><span style="color:#ff4d4f;">¥3.50</span>
+            </div>
+            <div class="order-item">
+              <span>奥利奥饼干 ×1</span><span style="color:#ff4d4f;">¥8.90</span>
+            </div>
+            <div class="order-item" style="color:#10B981;">
+              <span>优惠券抵扣</span><span>-¥2.00</span>
+            </div>
+            <div class="order-total">
+              <span>合计 <span class="pay-badge">支付分扣款</span></span>
+              <span style="color:#ff4d4f;">¥10.40</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="phone-label">④ 扣款成功 - 支付分自动扣款</div>
+    </div>
+
+    <!-- 页面5: 订单完成页 -->
+    <div>
+      <div class="phone-frame">
+        <div class="phone-statusbar">9:43 &nbsp;&nbsp; 100%</div>
+        <div class="phone-nav">订单详情</div>
+        <div class="phone-body">
+          <div class="success-icon">✅</div>
+          <div style="text-align:center;font-size:16px;font-weight:700;color:#333;">支付成功</div>
+          <div style="text-align:center;font-size:12px;color:#999;margin-top:4px;">
+            微信支付分自动扣款
+          </div>
+
+          <div class="order-items" style="margin-top:16px;">
+            <div style="font-size:12px;font-weight:600;margin-bottom:8px;">订单明细</div>
+            <div class="order-item">
+              <span>可口可乐 330ml</span>
+              <span>×1 &nbsp; ¥3.50</span>
+            </div>
+            <div class="order-item">
+              <span>奥利奥饼干 97g</span>
+              <span>×1 &nbsp; ¥8.90</span>
+            </div>
+            <div class="order-item" style="color:#10B981;">
+              <span>优惠金额</span>
+              <span>-¥2.00</span>
+            </div>
+            <div class="order-total">
+              <span>实付款</span>
+              <span style="color:#ff4d4f;font-size:18px;">¥10.40</span>
+            </div>
+          </div>
+
+          <div style="background:#fff;border-radius:10px;padding:12px;margin-top:12px;font-size:11px;color:#666;line-height:2;">
+            <div style="font-weight:600;color:#333;margin-bottom:4px;">订单信息</div>
+            <div style="display:flex;justify-content:space-between;"><span>订单编号</span><span>HH20260511...</span></div>
+            <div style="display:flex;justify-content:space-between;"><span>支付方式</span><span style="color:#07C160;">微信支付分</span></div>
+            <div style="display:flex;justify-content:space-between;"><span>支付时间</span><span>2026-05-11 09:43</span></div>
+          </div>
+
+          <div style="text-align:center;margin-top:12px;">
+            <div style="display:inline-block;padding:6px 20px;border:1px solid #e0e0e0;
+              border-radius:16px;font-size:11px;color:#666;margin-right:8px;">申请退款</div>
+            <div style="display:inline-block;padding:6px 20px;background:#FFC107;
+              border-radius:16px;font-size:11px;color:#333;font-weight:600;">回到首页</div>
+          </div>
+          <div style="text-align:center;margin-top:8px;font-size:10px;color:#aaa;">
+            7天内可申请退款
+          </div>
+        </div>
+      </div>
+      <div class="phone-label">⑤ 订单完成页 - 含退款入口</div>
+    </div>
+
+  </div>
+
+  <!-- 底部说明 -->
+  <div class="footer-note">
+    流程说明:①扫码进入小程序 → ②查看商品/价格 → ③开通微信支付分(≥550分)→ ④开门选购、AI识别、支付分自动扣款 → ⑤订单完成<br>
+    接入渠道:微信小程序 | 计费规则:按商品零售价计费,免押金,先享后付,支持优惠券抵扣 | 退款规则:7天内可申请退款
+  </div>
+</div>
+
+</body>
+</html>

+ 599 - 0
docs/项目开发报价方案.md

@@ -0,0 +1,599 @@
+# 哈哈智能售卖柜平台 — 软件开发报价方案
+
+> **文档版本**: V1.0
+> **编制日期**: 2026年05月07日
+> **保密级别**: 商业机密
+
+---
+
+## 目录
+
+- [一、项目概述](#一项目概述)
+- [二、技术方案](#二技术方案)
+- [三、开发范围与功能清单](#三开发范围与功能清单)
+- [四、工作量估算明细](#四工作量估算明细)
+- [五、项目排期计划](#五项目排期计划)
+- [六、报价方案](#六报价方案)
+- [七、交付物清单](#七交付物清单)
+- [八、售后与运维服务](#八售后与运维服务)
+- [九、费用说明](#九费用说明)
+- [十、合作流程](#十合作流程)
+
+---
+
+## 一、项目概述
+
+### 1.1 项目背景
+
+本项目旨在构建一套完整的**智能售卖柜无人零售平台**,覆盖用户扫码购物、设备管理、商品管理、订单支付、营销活动、分销推广、补货管理等全业务链路。
+
+### 1.2 项目组成
+
+| 序号 | 子系统 | 说明 | 技术栈 |
+|------|--------|------|--------|
+| 1 | 后端服务(双应用) | 运营平台后端 + 小程序后端 | Java 21 / Spring Boot 4 / MyBatis-Plus / Sa-Token |
+| 2 | 运营管理平台(Web) | 运营人员使用的后台管理系统 | Vue 3 / Element Plus / TypeScript / Vite / Pinia |
+| 3 | 用户端微信小程序 | 面向消费者的购物小程序 | UniApp / Vue 3 / TypeScript / 微信支付分 |
+| 4 | 管理端微信小程序 | 补货员/合作商使用的管理工具 | UniApp / Vue 3 / TypeScript |
+| 5 | 哈哈设备SDK | 与智能售卖机设备平台的对接层 | Java / MQTT |
+
+### 1.3 核心业务流程
+
+```
+用户扫码 → 支付分鉴权 → 远程开门 → 选购商品 → 关门结算
+    → AI识别商品 → 设备端计算金额 → 优惠券自动扣减 → 支付分扣费
+    → 订单完成 → 分销佣金计算 → 邀请奖励激活
+```
+
+---
+
+## 二、技术方案
+
+### 2.1 后端架构
+
+| 项目 | 技术选型 |
+|------|----------|
+| 开发语言 | Java 21 |
+| 基础框架 | Spring Boot 4.0.3(Jakarta 命名空间) |
+| ORM框架 | MyBatis-Plus 3.5.16 |
+| 权限认证 | Sa-Token 1.45.0 |
+| 数据库 | MySQL 8.0+ |
+| 缓存 | Redis 7.0+ |
+| 消息队列 | MQTT(设备通信) |
+| 构建工具 | Maven |
+| 容器化 | Docker |
+
+**双应用架构**:
+
+```
+haha-admin(运营平台,端口7070)
+    └── 共享 haha-service → haha-mapper → haha-entity → haha-common
+haha-miniapp(小程序,端口7077)
+    └── 共享 haha-service → haha-mapper → haha-entity → haha-common
+haha-sdk(独立设备对接模块)
+```
+
+### 2.2 前端架构
+
+| 应用 | 技术栈 | 构建工具 |
+|------|--------|----------|
+| 运营管理平台 Web | Vue 3.5 + Element Plus 2.12 + TypeScript 5.9 + Tailwind CSS 4 | Vite 7 |
+| 用户端小程序 | UniApp 3.0 + Vue 3.5 + TypeScript 4.9 + Pinia 3 | Vite 5 |
+| 管理端小程序 | UniApp 3.0 + Vue 3.5 + TypeScript 4.9 + Pinia 3 | Vite 5 |
+
+### 2.3 集成服务
+
+| 服务 | 用途 |
+|------|------|
+| 微信支付(JSAPI) | 小程序内支付 |
+| 微信支付分 | 免密支付(先享后付) |
+| 微信登录 | 手机号快速登录 |
+| 高德地图 | 门店选址、附近门店搜索 |
+| 阿里云OSS / 腾讯云COS | 商品图片、视频存储 |
+| 哈哈设备平台 | 智能售卖机设备对接(开门/状态/商品/订单回调) |
+
+---
+
+## 三、开发范围与功能清单
+
+### 3.1 后端服务功能
+
+| 编号 | 功能模块 | 功能描述 |
+|------|----------|----------|
+| B-01 | 设备管理 | 设备信息CRUD、在线/离线状态追踪、门状态监控、远程开门、温度/音量控制 |
+| B-02 | 门店管理 | 门店CRUD、设备关联、省市区选择、经纬度选址、营业时间、附近门店搜索 |
+| B-03 | 商品管理 | 商品CRUD、分类管理、图片管理、与哈哈平台同步、新品申请审核 |
+| B-04 | 订单管理 | 订单创建/查询/列表/详情、多维筛选、三级金额体系(原价/优惠/实付) |
+| B-05 | 支付体系 | 微信支付(JSAPI)、微信支付分(免密支付)、支付宝支付、退款管理、支付回调 |
+| B-06 | 设备回调处理 | AI识别回调、订单金额回调、设备状态回调、在线状态回调、声音/新品/商品合并回调 |
+| B-07 | 营销活动 | 首单立减/折扣/满减三种活动类型、活动状态流转、适用范围配置、预算控制 |
+| B-08 | 定时折扣 | 固定折扣/阶梯折扣/固定价格、按时段自动执行、临期清库存策略 |
+| B-09 | 优惠券体系 | 满减券/折扣券/立减券/商品券四种类型、有效期管理、库存防超卖、自动扣减 |
+| B-10 | 邀请有礼 | 邀请活动配置、邀请码生成、邀请关系绑定、首单激活奖励、双向发券 |
+| B-11 | 分销管理 | 合作商CRUD、推荐记录、佣金计算(含冻结期)、提现申请与审核、月度报表 |
+| B-12 | 库存管理 | 设备库存查询(货架/货道维度)、库存预警、出入库日志 |
+| B-13 | 补货员管理 | 独立补货员CRUD、微信扫码绑定、设备权限分配、补货操作记录 |
+| B-14 | 用户/客户管理 | 用户列表/详情、订单历史、消费统计 |
+| B-15 | 权限与系统管理 | 管理员管理、角色管理(含菜单/权限分配)、菜单管理、部门管理、字典管理、操作日志、公告管理 |
+| B-16 | 公告管理 | 公告CRUD、发布/下架、小程序端推送 |
+
+### 3.2 运营管理平台 Web
+
+| 编号 | 页面 | 功能描述 |
+|------|------|----------|
+| W-01 | 登录页 | 账号密码登录、手机验证码登录、二维码登录 |
+| W-02 | 仪表盘/工作台 | 核心指标卡片、销售趋势图表、快捷入口 |
+| W-03 | 设备管理 | 设备列表(分页/筛选/搜索)、设备详情、远程开门、温度/音量设置 |
+| W-04 | 门店管理 | 门店列表、新增/编辑(地图选址)、门店详情(设备数/在线数) |
+| W-05 | 商品管理 | 商品列表(多条件筛选)、新增/编辑、图片管理/预览、新品申请审核 |
+| W-06 | 订单管理 | 订单列表(状态/设备/门店/时间多维筛选)、订单详情(金额/商品/视频)、退款审核 |
+| W-07 | 库存管理 | 设备库存查询、库存预警列表、出入库记录 |
+| W-08 | 营销中心 | 营销活动管理、优惠券模板管理、用户优惠券查询、定时折扣管理、邀请有礼管理、营销数据统计 |
+| W-09 | 分销管理 | 合作商列表/详情、推荐记录、佣金记录、提现审核、月度报表、分销配置 |
+| W-10 | 补货员管理 | 补货员列表、新增/编辑、生成微信绑定码、设备权限分配 |
+| W-11 | 客户管理 | 用户列表、用户详情(消费记录/优惠券/统计) |
+| W-12 | 数据统计 | 销售概览、设备统计、商品统计、门店统计、品类分析、复购率、利润分析 |
+| W-13 | 系统管理 | 管理员管理、角色管理(菜单权限+操作权限分配)、菜单管理(树形)、部门管理、字典管理 |
+| W-14 | 操作日志 | 操作日志查询(模块/操作人/时间筛选) |
+| W-15 | 公告管理 | 公告列表/新增/编辑/发布/删除 |
+| W-16 | 个人中心 | 个人资料、修改密码、主题切换(亮色/暗黑) |
+
+### 3.3 用户端微信小程序
+
+| 编号 | 页面 | 功能描述 |
+|------|------|----------|
+| M-01 | 登录页 | 微信手机号快速登录(open-type="getPhoneNumber") |
+| M-02 | 首页 | 扫码入口、附近门店/设备、公告轮播、快捷入口 |
+| M-03 | 扫码购物 | 扫码→开门→购物页商品展示→关门自动结算(完整闭环) |
+| M-04 | 支付分 | 支付分开通引导、服务授权、信用记录 |
+| M-05 | 订单列表 | 全部/待支付/已完成/已关闭 分类筛选 |
+| M-06 | 订单详情 | 商品明细、三级金额(原价/优惠/实付)、支付状态、识别视频 |
+| M-07 | 退款申请 | 退款申请(7天内)、退款状态跟踪 |
+| M-08 | 领券中心 | 优惠券浏览、领取(防超卖)、领取状态 |
+| M-09 | 我的优惠券 | 未使用/已使用/已过期 分类、券详情 |
+| M-10 | 邀请有礼 | 邀请海报/链接生成、邀请记录、奖励记录 |
+| M-11 | 公告中心 | 公告列表、公告详情 |
+| M-12 | 个人中心 | 个人信息、订单入口、优惠券入口、邀请入口、客服、设置 |
+| M-13 | 协议页面 | 隐私政策、用户服务协议 |
+
+### 3.4 管理端微信小程序
+
+| 编号 | 页面 | 功能描述 |
+|------|------|----------|
+| A-01 | 登录/绑定 | 微信绑定码绑定、静默登录、补货员身份校验 |
+| A-02 | 工作台首页 | 核心指标、待办事项、快捷入口 |
+| A-03 | 补货工作台 | 绑定设备列表、设备库存查看、执行补货操作 |
+| A-04 | 设备管理 | 设备列表、设备详情(含库存/状态) |
+| A-05 | 订单管理 | 订单列表(按设备/状态筛选)、订单详情 |
+| A-06 | 库存管理 | 库存查询、库存预警列表 |
+| A-07 | 分销工作台 | 合作商面板、推荐列表、佣金记录、提现申请与记录 |
+| A-08 | 邀请管理 | 邀请活动、邀请记录查看 |
+| A-09 | 客户管理 | 用户列表、用户详情 |
+| A-10 | 门店管理 | 门店列表、门店详情 |
+| A-11 | 统计概览 | 关键数据看板 |
+
+### 3.5 哈哈SDK与设备对接
+
+| 编号 | 功能 | 说明 |
+|------|------|------|
+| S-01 | 设备API封装 | 开门、状态查询、温度/音量设置 |
+| S-02 | 商品API封装 | 商品查询、同步 |
+| S-03 | 订单API封装 | 创建订单、查询订单 |
+| S-04 | 认证与签名 | authToken认证、请求签名、回调签名验证 |
+| S-05 | 回调接收 | 设备状态/在线状态/AI识别/订单/音量/新品/商品合并共7种回调 |
+| S-06 | AI识别处理 | ORC_RESULT解析、sku_list处理、视频URL存储、幂等防重复 |
+| S-07 | MQTT通信 | 设备消息实时订阅、状态变更推送 |
+
+---
+
+## 四、工作量估算明细
+
+### 4.1 总览
+
+| 大类 | 模块数 | 总工时(人天) |
+|------|--------|---------------|
+| 一、项目基础设施 | 9 | 22 |
+| 二、后端核心服务 | 8个子模块 | 78 |
+| 三、运营管理平台 Web | 16 | 62 |
+| 四、用户端小程序 | 13 | 35 |
+| 五、管理端小程序 | 11 | 24 |
+| 六、SDK与设备对接 | 7 | 18 |
+| 七、联调测试与部署 | 3 | 18 |
+| **合计** | | **257 人天** |
+
+### 4.2 详细工时分解
+
+#### 一、项目基础设施搭建(22 人天)
+
+| 编号 | 功能点 | 工时 | 说明 |
+|------|--------|------|------|
+| 1.1 | Spring Boot 多模块项目初始化 | 2 | Maven多模块、Java 21、Jakarta命名空间 |
+| 1.2 | 数据库设计与建表(30+张表) | 3 | ER图设计、索引规划、雪花ID主键、建表SQL |
+| 1.3 | 公共模块开发(enums/constants/VO/Result/异常处理) | 3 | haha-common完整搭建 |
+| 1.4 | CI/CD与部署环境配置 | 2 | 开发/测试/生产环境、Docker、Nginx、域名SSL |
+| 1.5 | 三大前端项目脚手架搭建 | 3 | Vue3+Vite+ElementPlus / UniApp+Vue3 |
+| 1.6 | 统一HTTP请求封装与拦截 | 2 | axios封装 + uni.request封装、Token管理、401处理 |
+| 1.7 | 双认证体系搭建 | 3 | 运营端Sa-Token + 小程序微信登录 |
+| 1.8 | 文件存储服务对接 | 2 | 阿里云OSS/腾讯云COS |
+| 1.9 | 操作日志AOP与监控 | 2 | @Log注解、登录日志、接口调用日志 |
+| | **小计** | **22** | |
+
+#### 二、后端核心服务(78 人天)
+
+**设备与门店模块(12人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 设备管理CRUD(含SN唯一校验、状态追踪) | 2 |
+| 设备状态管理(在线/离线/门状态/历史记录) | 1.5 |
+| 门店管理CRUD(含省市区、地图选址) | 2 |
+| 门店-设备关联(一对多绑定、设备转移) | 1 |
+| 附近门店查询(经纬度+距离排序) | 1.5 |
+| 设备远程控制(开门、温度、音量) | 2 |
+| 开关门记录(DoorRecord、时长统计、无消费标记) | 2 |
+
+**订单与支付模块(24人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 订单CRUD(含多维筛选分页) | 2 |
+| AI识别回调处理(解析、创建订单、幂等) | 3 |
+| 订单金额回调处理(商品明细保存) | 2 |
+| 三级金额体系设计与计算 | 1.5 |
+| 支付策略架构(Strategy模式+渠道工厂) | 3 |
+| 微信支付对接(JSAPI支付+回调+验签) | 3 |
+| 微信支付分对接(开通+创单+扣款+撤销完整链路) | 4 |
+| 支付宝支付对接 | 2 |
+| 退款管理(申请+审核+7天限制+流水) | 2.5 |
+| 订单状态流转(4状态状态机) | 1 |
+
+**营销与优惠模块(22.5人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 营销活动CRUD(首单立减/折扣/满减三类型) | 3 |
+| 活动状态流转(5状态状态机) | 2 |
+| 活动适用范围(门店/设备/商品三级) | 2 |
+| 优惠券模板管理(满减/折扣/立减/商品券) | 3 |
+| 用户优惠券管理(领取/使用/过期) | 2 |
+| 领券中心(主动领取+限领+防超卖) | 2.5 |
+| 优惠券自动扣减(最早到期优先策略) | 2 |
+| 优惠叠加规则(活动+券叠加、上限) | 1.5 |
+| 定时折扣活动(固定/阶梯/固定价+自动执行) | 3 |
+| 统一活动管理(三套体系统一查询入口) | 1.5 |
+
+**分销模块(11人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 合作商管理CRUD(含二维码) | 2 |
+| 推荐记录(关系绑定、首单追踪) | 1.5 |
+| 佣金计算(冻结期+结算) | 2.5 |
+| 提现管理(申请+冻结+审核+打款) | 2.5 |
+| 月度报表 | 1.5 |
+| 合作商配置(佣金比例/提现门槛/冻结天数) | 1 |
+
+**邀请有礼模块(7.5人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 邀请活动CRUD(双向奖励/日上限/首单门槛) | 2.5 |
+| 邀请码生成与分享 | 1.5 |
+| 邀请关系绑定(扫码/链接+防重复) | 1.5 |
+| 邀请激活(首单完成+自动发券) | 2 |
+
+**库存管理模块(4人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 设备库存查询(货架/货道维度) | 1.5 |
+| 库存预警(阈值+自动告警) | 1.5 |
+| 库存日志(出入库追溯) | 1 |
+
+**补货员模块(9人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 补货员CRUD(独立表、姓名/手机/工号) | 1.5 |
+| 微信绑定流程(绑定码+Redis+扫码+openid) | 2.5 |
+| 设备权限绑定(手动/扫码/门店继承) | 1.5 |
+| 补货操作(扫码→查库存→逐项补货) | 2 |
+| 补货记录与明细(变更前后库存、统计) | 1.5 |
+
+**公告模块(2.5人天)**
+
+| 功能点 | 工时 |
+|--------|------|
+| 公告CRUD(创建/编辑/发布/删除) | 1.5 |
+| 小程序端公告推送 | 1 |
+
+#### 三、运营管理平台 Web(62 人天)
+
+| 编号 | 功能模块 | 工时 |
+|------|----------|------|
+| 3.1 | 登录与认证(账号/验证码/二维码三种方式) | 2 |
+| 3.2 | 布局框架(侧边栏/导航/标签页/面包屑/全屏/暗黑模式) | 3 |
+| 3.3 | 仪表盘/工作台(指标卡片+ECharts图表+快捷入口) | 3 |
+| 3.4 | 设备管理(列表/详情/远程控制) | 4 |
+| 3.5 | 门店管理(列表/新增编辑/详情/地图选址) | 3 |
+| 3.6 | 商品管理(列表/新增编辑/图片管理/新品申请审核) | 4 |
+| 3.7 | 订单管理(列表/详情/退款审核) | 5 |
+| 3.8 | 库存管理(设备库存查询/预警/出入库记录) | 3 |
+| 3.9 | 营销中心(活动+优惠券+定时折扣+邀请+统计) | 8 |
+| 3.10 | 分销管理(合作商+推荐+佣金+提现审核+报表) | 6 |
+| 3.11 | 补货员管理(列表/增删改/绑定码生成/权限分配) | 3 |
+| 3.12 | 客户管理(用户列表/详情/消费统计) | 3 |
+| 3.13 | 数据统计(7个维度统计面板) | 5 |
+| 3.14 | 系统管理(管理员/角色/菜单/部门/字典/日志/公告) | 8 |
+| 3.15 | 个人中心(资料/密码/安全) | 2 |
+
+#### 四、用户端微信小程序(35 人天)
+
+| 编号 | 功能模块 | 工时 |
+|------|----------|------|
+| 4.1 | 登录模块(微信手机号登录+静默登录+登录态维持) | 2 |
+| 4.2 | 首页(扫码入口+附近门店+公告轮播+快捷入口) | 3 |
+| 4.3 | 扫码购物流程(完整闭环:扫码→开门→选购→结算) | 5 |
+| 4.4 | 支付分模块(开通引导+服务详情+信用记录) | 3 |
+| 4.5 | 订单模块(列表+详情+三级金额+状态追踪) | 4 |
+| 4.6 | 退款模块(申请+7天限制+状态跟踪) | 2 |
+| 4.7 | 领券中心(列表+领取+防超卖) | 2 |
+| 4.8 | 我的优惠券(四分类+使用记录) | 2 |
+| 4.9 | 邀请有礼(海报生成+记录+奖励) | 2 |
+| 4.10 | 公告中心(列表+详情) | 1.5 |
+| 4.11 | 个人中心(信息+入口聚合+客服+设置) | 3 |
+| 4.12 | 协议页面(隐私政策+服务协议) | 1 |
+| 4.13 | 全局组件(骨架屏+客服按钮+导航栏+空状态+加载动画) | 2.5 |
+| 4.14 | 设计与适配(品牌黄主题+Design Token+安全区+rpx适配) | 2 |
+
+#### 五、管理端小程序(24 人天)
+
+| 编号 | 功能模块 | 工时 |
+|------|----------|------|
+| 5.1 | 登录/绑定(绑定码绑定+静默登录+身份校验) | 2 |
+| 5.2 | 工作台首页(指标概览+待办+快捷入口) | 2 |
+| 5.3 | 补货工作台(设备列表+库存查询+执行补货) | 4 |
+| 5.4 | 设备管理(列表+详情) | 2 |
+| 5.5 | 订单管理(列表+详情) | 3 |
+| 5.6 | 库存管理(查询+预警) | 2 |
+| 5.7 | 分销管理(面板+推荐+佣金+提现) | 3 |
+| 5.8 | 邀请管理(活动+记录) | 1.5 |
+| 5.9 | 客户管理(列表+详情) | 1.5 |
+| 5.10 | 门店管理(列表+详情) | 1.5 |
+| 5.11 | 统计概览(数据看板) | 1.5 |
+
+#### 六、哈哈SDK与设备对接(18 人天)
+
+| 编号 | 功能点 | 工时 |
+|------|--------|------|
+| 6.1 | SDK核心封装(设备/商品/订单API) | 5 |
+| 6.2 | 签名与认证(authToken+请求签名+回调验签) | 3 |
+| 6.3 | 回调接收与分发(7种回调+消息路由+幂等) | 4 |
+| 6.4 | AI识别处理(ORC_RESULT解析+sku_list+视频+幂等) | 3 |
+| 6.5 | MQTT消息推送(连接管理+订阅+实时更新) | 3 |
+
+#### 七、联调测试与部署(18 人天)
+
+| 编号 | 阶段 | 工时 |
+|------|------|------|
+| 7.1 | 前后端接口联调 | 5 |
+| 7.2 | 功能测试+Bug修复(含2-3轮回归) | 8 |
+| 7.3 | 生产部署+小程序审核+文档交付 | 5 |
+
+---
+
+## 五、项目排期计划
+
+### 5.1 人员配置建议
+
+| 角色 | 人数 | 职责 |
+|------|------|------|
+| 后端开发工程师 | 2人 | Java后端服务、SDK、设备对接、数据库 |
+| 前端开发工程师 | 1-2人 | 运营管理平台Web |
+| 小程序开发工程师 | 1人 | 用户端+管理端两个小程序 |
+| 项目经理(可选) | 1人 | 需求对接、进度把控、验收 |
+
+### 5.2 分阶段交付计划
+
+| 阶段 | 周期 | 交付物 | 核心里程碑 |
+|------|------|--------|------------|
+| **阶段一:基础搭建** | 第1-2周 | 项目脚手架、数据库建表、公共模块、认证体系、三大前端框架 | 前后端工程可运行 |
+| **阶段二:核心闭环** | 第3-5周 | 设备/门店/商品管理、扫码购物MVP、订单/支付(含支付分)、运营后台基础框架 | 可演示扫码购物完整闭环 |
+| **阶段三:业务完善** | 第6-8周 | 营销/优惠券/定时折扣、分销/邀请体系、补货员系统、管理端小程序、数据统计 | 全业务功能开发完成 |
+| **阶段四:测试上线** | 第9-10周 | 全量联调、功能测试、性能测试、生产部署、小程序审核、文档交付 | 正式上线运行 |
+
+```
+周次    1    2    3    4    5    6    7    8    9    10
+────  ──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──
+后端    ██  ██  ██  ██  ██  ██  ██  ██  ▒▒  ▒▒
+前端Web      ██  ██  ██  ██  ██  ██  ▒▒  ▒▒
+小程序                  ██  ██  ██  ██  ▒▒  ▒▒
+SDK                 ██  ██  ██
+测试                                       ▒▒  ▒▒
+────  ──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──
+      ██ = 开发    ▒▒ = 测试/部署
+```
+
+### 5.3 关键路径依赖
+
+```
+数据库设计 → 公共模块 → 认证体系
+    ↓
+设备/门店管理 → 扫码开门 → 支付分 → AI回调 → 订单支付 → 购物闭环
+    ↓
+营销/优惠券 → 分销/邀请 → 补货员 → 统计报表
+    ↓
+全量联调测试 → 上线部署
+```
+
+---
+
+## 六、报价方案
+
+### 6.1 方案对比
+
+| 项目 | 基础版 | 标准版(推荐) | 完整版 |
+|------|--------|---------------|--------|
+| **人员配置** | 4人(2后端+1前端+1小程序) | 5人(2后端+2前端+1小程序) | 6人(2后端+2前端+1小程序+1PM/测试) |
+| **开发周期** | 12-14周 | 10-12周 | 10周 |
+| **总人天** | 257 | 257 | 257 |
+| **人天单价** | ¥1,200 | ¥1,500 | ¥1,800 |
+| **开发总价** | **¥308,400** | **¥385,500** | **¥462,600** |
+| **项目管理费** | 含在单价中 | 含在单价中 | 含在单价中 |
+| **功能完整度** | 核心功能 | 全部功能 | 全部功能+专业测试 |
+| **测试覆盖** | 基础功能测试 | 功能+兼容性测试 | 功能+兼容性+性能+安全测试 |
+| **文档交付** | 接口文档+部署文档 | 接口+部署+设计文档 | 全套技术文档+用户手册+运维手册 |
+| **售后维护** | 1个月免费 | 3个月免费 | 6个月免费 |
+
+> **推荐标准版**:功能完整、周期合理、性价比最高。
+
+### 6.2 报价总览(标准版)
+
+| 费用项目 | 金额(元) | 说明 |
+|----------|-----------|------|
+| 后端服务开发 | ¥231,000 | 后端双应用 + SDK + 设备对接(78+18=96人天 × ¥1,500) |
+| 运营管理平台 Web | ¥93,000 | 62人天 × ¥1,500 |
+| 用户端小程序 | ¥52,500 | 35人天 × ¥1,500 |
+| 管理端小程序 | ¥36,000 | 24人天 × ¥1,500 |
+| 基础设施搭建 | ¥33,000 | 22人天 × ¥1,500 |
+| 联调测试与部署 | ¥27,000 | 18人天 × ¥1,500 |
+| 售后维护(3个月) | ¥0 | 标准版赠送 |
+| **合计(含税)** | **¥472,500** | |
+
+### 6.3 付款方式建议
+
+| 阶段 | 比例 | 金额(标准版) | 支付节点 |
+|------|------|---------------|----------|
+| 项目启动款 | 30% | ¥141,750 | 合同签订后3个工作日内 |
+| 中期款 | 30% | ¥141,750 | 阶段二完成(购物闭环可演示) |
+| 尾款 | 40% | ¥189,000 | 全部功能验收通过、正式上线后7个工作日内 |
+
+---
+
+## 七、交付物清单
+
+| 类别 | 交付物 | 说明 |
+|------|--------|------|
+| **源代码** | 全部项目源码 | 含后端双应用、Web管理后台、两个小程序、SDK |
+| **数据库** | 数据库建表脚本 | 含初始化数据(字典、默认角色、菜单等) |
+| **部署** | Docker镜像 + docker-compose | 一键部署脚本 |
+| **文档** | 接口文档(Swagger/OpenAPI) | 全部REST API接口说明 |
+| | 数据库设计文档 | ER图 + 表结构说明 |
+| | 部署运维文档 | 环境要求、部署步骤、常见问题 |
+| | 业务模块说明文档 | 已有8份模块设计文档 + 补充 |
+| | 用户操作手册 | 运营后台使用说明 |
+| **测试** | 测试报告 | 功能测试覆盖清单 + 测试结果 |
+| **培训** | 远程培训 | 运营人员使用培训(2小时) |
+
+---
+
+## 八、售后与运维服务
+
+### 8.1 免费维护期(标准版:上线后3个月)
+
+| 服务内容 | 说明 |
+|----------|------|
+| Bug修复 | 开发方原因导致的Bug免费修复 |
+| 技术咨询 | 工作时间内的远程技术支持 |
+| 小范围调整 | 界面文案、配置参数等视情况免费调整 |
+
+### 8.2 年度运维服务(可选,另签合同)
+
+| 服务内容 | 说明 | 参考费用 |
+|----------|------|----------|
+| 服务器运维 | 健康监控、日志清理、证书续期、安全补丁 | 开发总价10%-15%/年 |
+| 功能迭代 | 新增小型功能、业务流程优化 | 按实际人天 ¥1,500/天 |
+| 紧急响应 | 7×24小时紧急问题响应 | 含在年费中 |
+
+---
+
+## 九、费用说明
+
+### 9.1 报价包含
+
+- 全部功能的设计、开发、测试、部署
+- 项目管理和需求沟通
+- 上线后免费维护期内的Bug修复
+
+### 9.2 报价不包含
+
+| 项目 | 说明 |
+|------|------|
+| 服务器费用 | 云服务器/数据库/Redis/OSS等资源采购 |
+| 域名与SSL证书 | 域名注册、SSL证书购买与续费 |
+| 第三方服务费 | 微信支付手续费、支付宝手续费、高德地图API调用费、短信验证码费用 |
+| 微信认证费用 | 小程序认证费(¥300/年) |
+| 硬件设备 | 智能售卖柜硬件采购与调试 |
+| 需求变更 | 合同签订后新增或重大变更的功能需求 |
+| 异地差旅 | 如需现场部署或培训产生的差旅费 |
+
+---
+
+## 十、合作流程
+
+```
+1. 需求确认
+   ├── 双方沟通确认功能范围
+   └── 确认报价方案与交付周期
+
+2. 合同签订
+   ├── 签署软件开发合同
+   └── 支付项目启动款(30%)
+
+3. 设计阶段
+   ├── UI/UX设计稿确认
+   └── 数据库详细设计评审
+
+4. 开发阶段
+   ├── 按阶段计划开发
+   ├── 每周进度同步
+   └── 阶段交付物验收
+
+5. 测试阶段
+   ├── 内部功能测试
+   ├── 需求方UAT验收测试
+   └── 支付中期款(30%)
+
+6. 上线部署
+   ├── 生产环境部署
+   ├── 数据迁移(如有)
+   ├── 小程序提交审核
+   └── 正式上线
+
+7. 项目交付
+   ├── 全部文档交付
+   ├── 操作培训
+   └── 支付尾款(40%)
+
+8. 售后维护
+   └── 免费维护期开始
+```
+
+---
+
+## 附录
+
+### A. 技术名词解释
+
+| 名词 | 说明 |
+|------|------|
+| UniApp | 跨平台小程序开发框架,一套代码可发布到微信/支付宝等多平台 |
+| Sa-Token | 轻量级Java权限认证框架 |
+| MyBatis-Plus | MyBatis增强工具,简化数据库操作 |
+| 支付分 | 微信支付分(先享后付),用户无需预充值即可购物,关门后自动扣款 |
+| 雪花算法 | 分布式唯一ID生成算法,用于数据库主键 |
+| MQTT | 物联网通信协议,用于设备与服务器之间的消息推送 |
+| 回调 | 设备端主动向服务端推送事件(如开关门、AI识别结果) |
+| OSS/COS | 阿里云/腾讯云对象存储服务,用于图片和视频文件存储 |
+
+### B. 联系方式
+
+| 角色 | 联系方式 |
+|------|----------|
+| 项目负责人 | (待填写) |
+| 技术负责人 | (待填写) |
+| 商务对接 | (待填写) |
+
+---
+
+> **声明**:本报价方案基于当前需求分析,实际开发过程中如发生需求变更,双方协商后可对报价和排期进行调整。本方案有效期为发出之日起30个自然日。

+ 20 - 0
haha-admin-web/CHANGELOG.md

@@ -1,3 +1,23 @@
+# 7.0.0 (2026-05-13)
+
+### ✔️ Refactor
+
+- Upgraded vue-pure-admin framework to v7.0.0
+- Upgraded Vite from v7 to v8 (Rolldown + Oxc)
+- Upgraded Vue Router from v4 to v5 (navigation guard `next()` replaced with `return`)
+- Upgraded TypeScript from v5 to v6
+- Upgraded ESLint from v9 to v10
+- Upgraded Stylelint from v16 to v17
+- Renamed `ShowModel` to `TagsStyle` in platform config
+- Added `Watermark` and `WatermarkText` config support
+- CDN mode removed `pinia` and `vue-demi` dependencies
+- Removed `v3-infinite-loading` dependency (replaced with `useInfiniteScroll`)
+- Removed `cross-env` dependency (Vite v8 natively supports NODE_OPTIONS)
+- Updated `types/directives.d.ts` to use `GlobalDirectives` instead of `ComponentCustomProperties`
+- Added `Perms` global component for button-level permission control
+- Updated `build/plugins.ts` CDN import to dynamic import
+- Updated `vite-plugin-router-warn` to v2
+
 # 6.3.0 (2025-12-10)
 
 ### ✔️ Refactor

+ 14 - 0
haha-admin/src/main/resources/db/migration/V2__add_order_version_field.sql

@@ -0,0 +1,14 @@
+-- =============================================
+-- 为订单表添加乐观锁版本号字段
+-- 版本:2.0
+-- 日期:2026-05-01
+-- 说明:添加 version 字段用于 MyBatis-Plus 乐观锁
+--       防止并发更新订单时出现数据覆盖
+-- =============================================
+
+-- 添加版本号字段(默认值为1,与 Order.java 实体中 `version = 1` 保持一致)
+ALTER TABLE `t_order`
+ADD COLUMN IF NOT EXISTS `version` int(11) DEFAULT 1 NOT NULL COMMENT '乐观锁版本号,用于防止并发更新' AFTER `id`;
+
+-- 初始化已有数据的版本号(如果某些行的 version 为 NULL)
+-- UPDATE `t_order` SET `version` = 1 WHERE `version` IS NULL;

+ 14 - 0
haha-admin/src/main/resources/db/migration/V3__add_order_shop_fields.sql

@@ -0,0 +1,14 @@
+-- =============================================
+-- 订单表新增门店字段
+-- 版本:3.0
+-- 日期:2026-05-12
+-- 说明:将门店信息落库,避免设备换门店后历史订单门店信息失真
+--       shop_id      - 门店ID,下单时从设备写入
+--       store_name   - 门店名称快照,下单时冗余写入
+-- =============================================
+
+ALTER TABLE `t_order`
+ADD COLUMN IF NOT EXISTS `shop_id` BIGINT DEFAULT NULL COMMENT '门店ID(下单时从设备所属门店写入)' AFTER `device_id`;
+
+ALTER TABLE `t_order`
+ADD COLUMN IF NOT EXISTS `store_name` VARCHAR(100) DEFAULT NULL COMMENT '门店名称快照(下单时写入)' AFTER `shop_id`;

+ 36 - 1
haha-admin/src/main/resources/sql/inventory.sql

@@ -1,6 +1,6 @@
 -- 设备商品库存表
 CREATE TABLE IF NOT EXISTS `t_device_inventory` (
-    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `id` BIGINT NOT NULL COMMENT '主键ID(雪花算法生成)',
     `device_id` VARCHAR(50) NOT NULL COMMENT '设备ID(SN号)',
     `product_id` BIGINT NOT NULL COMMENT '商品ID(本地)',
     `product_code` VARCHAR(50) DEFAULT NULL COMMENT '商品编码(哈哈平台code)',
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `t_device_inventory` (
     `last_restock_time` DATETIME DEFAULT NULL COMMENT '最后补货时间',
     `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `version` INT DEFAULT 0 COMMENT '乐观锁版本号',
     PRIMARY KEY (`id`),
     UNIQUE KEY `uk_device_product` (`device_id`, `product_id`),
     INDEX `idx_device_id` (`device_id`),
@@ -101,3 +102,37 @@ CREATE TABLE IF NOT EXISTS `t_stocker` (
     INDEX `idx_phone` (`phone`),
     INDEX `idx_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上货员表';
+
+-- ==================== 补货员新表(v2 独立账号体系) ====================
+
+-- 补货员表(替代 t_stocker,独立账号体系)
+CREATE TABLE IF NOT EXISTS `t_replenisher` (
+    `id` BIGINT NOT NULL COMMENT '雪花主键',
+    `name` VARCHAR(50) NOT NULL COMMENT '姓名',
+    `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
+    `employee_id` VARCHAR(50) DEFAULT NULL COMMENT '工号',
+    `wechat_openid` VARCHAR(100) DEFAULT NULL COMMENT '微信OpenID(扫码绑定时回填)',
+    `avatar` VARCHAR(500) DEFAULT NULL COMMENT '头像',
+    `status` TINYINT DEFAULT 1 COMMENT '状态:1-启用 0-禁用',
+    `total_tasks` INT DEFAULT 0 COMMENT '累计任务数',
+    `last_task_time` DATETIME DEFAULT NULL COMMENT '最后任务时间',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    INDEX `idx_phone` (`phone`),
+    INDEX `idx_status` (`status`),
+    INDEX `idx_wechat_openid` (`wechat_openid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='补货员表';
+
+-- 补货员-设备关联表
+CREATE TABLE IF NOT EXISTS `t_replenisher_device` (
+    `id` BIGINT NOT NULL COMMENT '雪花主键',
+    `replenisher_id` BIGINT NOT NULL COMMENT '补货员ID',
+    `device_id` VARCHAR(50) NOT NULL COMMENT '设备SN号',
+    `source` VARCHAR(20) DEFAULT 'MANUAL' COMMENT '绑定来源:MANUAL-手动,QR-扫码绑定,SHOP_INHERIT-门店继承',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_replenisher_device` (`replenisher_id`, `device_id`),
+    INDEX `idx_device_id` (`device_id`),
+    INDEX `idx_replenisher_id` (`replenisher_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='补货员-设备关联表';

+ 766 - 0
haha-admin/src/test/java/com/haha/admin/controller/ReplenisherControllerTest.java

@@ -0,0 +1,766 @@
+package com.haha.admin.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.haha.entity.Replenisher;
+import com.haha.entity.dto.*;
+import com.haha.service.ReplenisherService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+/**
+ * 补货员管理控制器 - 完整单元测试
+ * <p>
+ * 使用 MockMvc standalone 模式覆盖 ReplenisherController 全部 12 个 API 端点,
+ * 包括正常流程、边界条件和异常场景。
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("补货员管理控制器")
+class ReplenisherControllerTest {
+
+    @Mock
+    private ReplenisherService replenisherService;
+
+    @InjectMocks
+    private ReplenisherController controller;
+
+    private MockMvc mockMvc;
+    private ObjectMapper objectMapper;
+
+    @BeforeEach
+    void setUp() {
+        objectMapper = new ObjectMapper();
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    /**
+     * 创建测试用的补货员对象
+     */
+    private Replenisher createTestReplenisher(Long id) {
+        Replenisher r = new Replenisher();
+        r.setId(id);
+        r.setName("测试补货员");
+        r.setPhone("13800138000");
+        r.setEmployeeId("EMP001");
+        r.setStatus(1);
+        r.setTotalTasks(0);
+        r.setBoundDeviceCount(0);
+        r.setCreateTime(LocalDateTime.now());
+        r.setUpdateTime(LocalDateTime.now());
+        return r;
+    }
+
+    /**
+     * 构建 MyBatis Plus 分页结果
+     */
+    private IPage<Replenisher> buildPage(List<Replenisher> records, long total, long pageSize, long current) {
+        Page<Replenisher> page = new Page<>(current, pageSize);
+        page.setRecords(records);
+        page.setTotal(total);
+        page.setPages(pageSize > 0 ? (total + pageSize - 1) / pageSize : 0);
+        return page;
+    }
+
+    // ============================================================
+    // 1. 分页查询补货员列表 GET /replenishers/list
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenishers/list - 分页查询列表")
+    class ListTests {
+
+        @Test
+        @DisplayName("正常查询第一页")
+        void shouldListFirstPage() throws Exception {
+            Replenisher r1 = createTestReplenisher(1L);
+            Replenisher r2 = createTestReplenisher(2L);
+            r2.setName("张三");
+            IPage<Replenisher> page = buildPage(Arrays.asList(r1, r2), 20, 10, 1);
+
+            when(replenisherService.getPage(any(ReplenisherQueryDTO.class))).thenReturn(page);
+
+            mockMvc.perform(get("/replenishers/list")
+                            .param("page", "1")
+                            .param("pageSize", "10"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("查询成功"))
+                    .andExpect(jsonPath("$.data.list.length()").value(2))
+                    .andExpect(jsonPath("$.data.total").value(20))
+                    .andExpect(jsonPath("$.data.list[0].name").value("测试补货员"))
+                    .andExpect(jsonPath("$.data.list[1].name").value("张三"));
+
+            verify(replenisherService).getPage(argThat(dto ->
+                    dto.getPage() == 1 && dto.getPageSize() == 10));
+        }
+
+        @Test
+        @DisplayName("关键词搜索")
+        void shouldListWithKeyword() throws Exception {
+            Replenisher r = createTestReplenisher(1L);
+            r.setName("李四");
+            IPage<Replenisher> page = buildPage(Collections.singletonList(r), 1, 10, 1);
+
+            when(replenisherService.getPage(any(ReplenisherQueryDTO.class))).thenReturn(page);
+
+            mockMvc.perform(get("/replenishers/list")
+                            .param("page", "1")
+                            .param("pageSize", "10")
+                            .param("keyword", "李四"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.list.length()").value(1))
+                    .andExpect(jsonPath("$.data.list[0].name").value("李四"));
+
+            verify(replenisherService).getPage(argThat(dto ->
+                    "李四".equals(dto.getKeyword())));
+        }
+
+        @Test
+        @DisplayName("按状态筛选 - 仅启用")
+        void shouldFilterByStatusActive() throws Exception {
+            IPage<Replenisher> page = buildPage(Collections.emptyList(), 0, 10, 1);
+            when(replenisherService.getPage(any(ReplenisherQueryDTO.class))).thenReturn(page);
+
+            mockMvc.perform(get("/replenishers/list")
+                            .param("page", "1")
+                            .param("pageSize", "10")
+                            .param("status", "1"))
+                    .andExpect(status().isOk());
+
+            verify(replenisherService).getPage(argThat(dto ->
+                    dto.getStatus() != null && dto.getStatus() == 1));
+        }
+
+        @Test
+        @DisplayName("按状态筛选 - 仅禁用")
+        void shouldFilterByStatusDisabled() throws Exception {
+            IPage<Replenisher> page = buildPage(Collections.emptyList(), 0, 10, 1);
+            when(replenisherService.getPage(any(ReplenisherQueryDTO.class))).thenReturn(page);
+
+            mockMvc.perform(get("/replenishers/list")
+                            .param("page", "1")
+                            .param("pageSize", "10")
+                            .param("status", "0"))
+                    .andExpect(status().isOk());
+
+            verify(replenisherService).getPage(argThat(dto ->
+                    dto.getStatus() != null && dto.getStatus() == 0));
+        }
+
+        @Test
+        @DisplayName("空列表")
+        void shouldReturnEmptyList() throws Exception {
+            IPage<Replenisher> page = buildPage(Collections.emptyList(), 0, 10, 1);
+            when(replenisherService.getPage(any(ReplenisherQueryDTO.class))).thenReturn(page);
+
+            mockMvc.perform(get("/replenishers/list")
+                            .param("page", "1")
+                            .param("pageSize", "10"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.list.length()").value(0))
+                    .andExpect(jsonPath("$.data.total").value(0));
+        }
+    }
+
+    // ============================================================
+    // 2. 获取补货员详情 GET /replenishers/{id}
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenishers/{id} - 获取补货员详情")
+    class GetByIdTests {
+
+        @Test
+        @DisplayName("查询存在的补货员")
+        void shouldGetById() throws Exception {
+            Replenisher r = createTestReplenisher(1L);
+            r.setBoundDeviceCount(5);
+
+            when(replenisherService.getReplenisherDetail(1L)).thenReturn(r);
+
+            mockMvc.perform(get("/replenishers/1"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.id").value(1))
+                    .andExpect(jsonPath("$.data.name").value("测试补货员"))
+                    .andExpect(jsonPath("$.data.phone").value("13800138000"))
+                    .andExpect(jsonPath("$.data.employeeId").value("EMP001"))
+                    .andExpect(jsonPath("$.data.boundDeviceCount").value(5));
+
+            verify(replenisherService).getReplenisherDetail(1L);
+        }
+
+        @Test
+        @DisplayName("查询不存在的补货员返回404")
+        void shouldReturn404WhenNotFound() throws Exception {
+            when(replenisherService.getReplenisherDetail(999L)).thenReturn(null);
+
+            mockMvc.perform(get("/replenishers/999"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(404))
+                    .andExpect(jsonPath("$.message").value("补货员不存在"));
+        }
+
+        @Test
+        @DisplayName("已禁用的补货员")
+        void shouldGetDisabledReplenisher() throws Exception {
+            Replenisher r = createTestReplenisher(2L);
+            r.setStatus(0);
+
+            when(replenisherService.getReplenisherDetail(2L)).thenReturn(r);
+
+            mockMvc.perform(get("/replenishers/2"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.status").value(0));
+        }
+    }
+
+    // ============================================================
+    // 3. 关键词搜索补货员 GET /replenishers/search
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenishers/search - 关键词搜索")
+    class SearchTests {
+
+        @Test
+        @DisplayName("按姓名搜索")
+        void shouldSearchByName() throws Exception {
+            Replenisher r = createTestReplenisher(1L);
+            r.setName("王五");
+
+            when(replenisherService.searchByKeyword("王五"))
+                    .thenReturn(Collections.singletonList(r));
+
+            mockMvc.perform(get("/replenishers/search")
+                            .param("keyword", "王五"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.length()").value(1))
+                    .andExpect(jsonPath("$.data[0].name").value("王五"));
+
+            verify(replenisherService).searchByKeyword("王五");
+        }
+
+        @Test
+        @DisplayName("无关键词搜索(返回全部启用补货员)")
+        void shouldSearchWithEmptyKeyword() throws Exception {
+            when(replenisherService.searchByKeyword(isNull()))
+                    .thenReturn(Collections.emptyList());
+
+            mockMvc.perform(get("/replenishers/search"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.length()").value(0));
+
+            verify(replenisherService).searchByKeyword(isNull());
+        }
+
+        @Test
+        @DisplayName("搜索无结果")
+        void shouldReturnEmptyWhenNoMatch() throws Exception {
+            when(replenisherService.searchByKeyword("不存在"))
+                    .thenReturn(Collections.emptyList());
+
+            mockMvc.perform(get("/replenishers/search")
+                            .param("keyword", "不存在"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.length()").value(0));
+
+            verify(replenisherService).searchByKeyword("不存在");
+        }
+    }
+
+    // ============================================================
+    // 4. 创建补货员 POST /replenishers
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenishers - 创建补货员")
+    class CreateTests {
+
+        @Test
+        @DisplayName("创建成功")
+        void shouldCreateReplenisher() throws Exception {
+            ReplenisherCreateDTO dto = new ReplenisherCreateDTO();
+            dto.setName("新补货员");
+            dto.setPhone("13900139000");
+            dto.setEmployeeId("EMP002");
+
+            Replenisher saved = createTestReplenisher(10L);
+            saved.setName("新补货员");
+            saved.setPhone("13900139000");
+            saved.setEmployeeId("EMP002");
+
+            when(replenisherService.createReplenisher(any(ReplenisherCreateDTO.class)))
+                    .thenReturn(saved);
+
+            mockMvc.perform(post("/replenishers")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("创建成功"))
+                    .andExpect(jsonPath("$.data.id").value(10))
+                    .andExpect(jsonPath("$.data.name").value("新补货员"));
+
+            verify(replenisherService).createReplenisher(argThat(d ->
+                    "新补货员".equals(d.getName())));
+        }
+
+        @Test
+        @DisplayName("仅填姓名创建")
+        void shouldCreateWithNameOnly() throws Exception {
+            ReplenisherCreateDTO dto = new ReplenisherCreateDTO();
+            dto.setName("最简补货员");
+
+            Replenisher saved = createTestReplenisher(11L);
+            saved.setName("最简补货员");
+            saved.setPhone(null);
+            saved.setEmployeeId(null);
+
+            when(replenisherService.createReplenisher(any(ReplenisherCreateDTO.class)))
+                    .thenReturn(saved);
+
+            mockMvc.perform(post("/replenishers")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.name").value("最简补货员"));
+
+            verify(replenisherService).createReplenisher(any(ReplenisherCreateDTO.class));
+        }
+    }
+
+    // ============================================================
+    // 5. 更新补货员 PUT /replenishers/{id}
+    // ============================================================
+
+    @Nested
+    @DisplayName("PUT /replenishers/{id} - 更新补货员信息")
+    class UpdateTests {
+
+        @Test
+        @DisplayName("全字段更新")
+        void shouldUpdateAllFields() throws Exception {
+            ReplenisherUpdateDTO dto = new ReplenisherUpdateDTO();
+            dto.setName("更新后的名字");
+            dto.setPhone("13700137000");
+            dto.setEmployeeId("EMP003");
+            dto.setStatus(0);
+
+            Replenisher updated = createTestReplenisher(1L);
+            updated.setName("更新后的名字");
+            updated.setPhone("13700137000");
+            updated.setEmployeeId("EMP003");
+            updated.setStatus(0);
+
+            when(replenisherService.updateReplenisher(eq(1L), any(ReplenisherUpdateDTO.class)))
+                    .thenReturn(updated);
+
+            mockMvc.perform(put("/replenishers/1")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("更新成功"))
+                    .andExpect(jsonPath("$.data.name").value("更新后的名字"))
+                    .andExpect(jsonPath("$.data.status").value(0));
+
+            verify(replenisherService).updateReplenisher(eq(1L), argThat(d ->
+                    "更新后的名字".equals(d.getName()) && d.getStatus() == 0));
+        }
+
+        @Test
+        @DisplayName("部分字段更新")
+        void shouldPartialUpdate() throws Exception {
+            ReplenisherUpdateDTO dto = new ReplenisherUpdateDTO();
+            dto.setPhone("13600136000");
+
+            Replenisher updated = createTestReplenisher(1L);
+            updated.setPhone("13600136000");
+
+            when(replenisherService.updateReplenisher(eq(1L), any(ReplenisherUpdateDTO.class)))
+                    .thenReturn(updated);
+
+            mockMvc.perform(put("/replenishers/1")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.phone").value("13600136000"));
+
+            verify(replenisherService).updateReplenisher(eq(1L), any(ReplenisherUpdateDTO.class));
+        }
+    }
+
+    // ============================================================
+    // 6. 更新补货员状态 PUT /replenishers/{id}/status
+    // ============================================================
+
+    @Nested
+    @DisplayName("PUT /replenishers/{id}/status - 启用/禁用")
+    class UpdateStatusTests {
+
+        @Test
+        @DisplayName("禁用补货员")
+        void shouldDisableReplenisher() throws Exception {
+            StatusDTO dto = new StatusDTO();
+            dto.setStatus(0);
+
+            doNothing().when(replenisherService).updateStatus(1L, 0);
+
+            mockMvc.perform(put("/replenishers/1/status")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("状态更新成功"));
+
+            verify(replenisherService).updateStatus(1L, 0);
+        }
+
+        @Test
+        @DisplayName("启用补货员")
+        void shouldEnableReplenisher() throws Exception {
+            StatusDTO dto = new StatusDTO();
+            dto.setStatus(1);
+
+            doNothing().when(replenisherService).updateStatus(2L, 1);
+
+            mockMvc.perform(put("/replenishers/2/status")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200));
+
+            verify(replenisherService).updateStatus(2L, 1);
+        }
+    }
+
+    // ============================================================
+    // 7. 删除补货员 DELETE /replenishers/{id}
+    // ============================================================
+
+    @Nested
+    @DisplayName("DELETE /replenishers/{id} - 删除补货员")
+    class DeleteTests {
+
+        @Test
+        @DisplayName("删除成功")
+        void shouldDeleteReplenisher() throws Exception {
+            doNothing().when(replenisherService).deleteReplenisher(1L);
+
+            mockMvc.perform(delete("/replenishers/1"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("删除成功"));
+
+            verify(replenisherService).deleteReplenisher(1L);
+        }
+    }
+
+    // ============================================================
+    // 8. 获取已绑定设备 GET /replenishers/{id}/devices
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenishers/{id}/devices - 获取已绑定设备")
+    class GetBoundDevicesTests {
+
+        @Test
+        @DisplayName("有绑定设备")
+        void shouldReturnBoundDevices() throws Exception {
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Arrays.asList("DEV001", "DEV002", "DEV003"));
+
+            mockMvc.perform(get("/replenishers/1/devices"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.length()").value(3))
+                    .andExpect(jsonPath("$.data[0]").value("DEV001"))
+                    .andExpect(jsonPath("$.data[2]").value("DEV003"));
+
+            verify(replenisherService).getBoundDeviceIds(1L);
+        }
+
+        @Test
+        @DisplayName("无绑定设备")
+        void shouldReturnEmptyDeviceList() throws Exception {
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.emptyList());
+
+            mockMvc.perform(get("/replenishers/1/devices"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.length()").value(0));
+
+            verify(replenisherService).getBoundDeviceIds(1L);
+        }
+    }
+
+    // ============================================================
+    // 9. 绑定设备 POST /replenishers/{id}/devices
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenishers/{id}/devices - 绑定设备")
+    class BindDevicesTests {
+
+        @Test
+        @DisplayName("绑定成功")
+        void shouldBindDevices() throws Exception {
+            BindDeviceDTO dto = new BindDeviceDTO();
+            dto.setDeviceIds(Arrays.asList("DEV001", "DEV002"));
+
+            when(replenisherService.bindDevices(eq(1L), anyList())).thenReturn(2);
+
+            mockMvc.perform(post("/replenishers/1/devices")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("绑定完成"))
+                    .andExpect(jsonPath("$.data.success").value(2))
+                    .andExpect(jsonPath("$.data.total").value(2));
+
+            verify(replenisherService).bindDevices(eq(1L), eq(Arrays.asList("DEV001", "DEV002")));
+        }
+
+        @Test
+        @DisplayName("部分绑定成功")
+        void shouldPartialBind() throws Exception {
+            BindDeviceDTO dto = new BindDeviceDTO();
+            dto.setDeviceIds(Arrays.asList("DEV001", "DEV002", "DEV003", "DEV004"));
+
+            when(replenisherService.bindDevices(eq(1L), anyList())).thenReturn(3);
+
+            mockMvc.perform(post("/replenishers/1/devices")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.success").value(3))
+                    .andExpect(jsonPath("$.data.total").value(4));
+        }
+    }
+
+    // ============================================================
+    // 10. 解绑设备 DELETE /replenishers/{id}/devices/{deviceId}
+    // ============================================================
+
+    @Nested
+    @DisplayName("DELETE /replenishers/{id}/devices/{deviceId} - 解绑设备")
+    class UnbindDeviceTests {
+
+        @Test
+        @DisplayName("解绑成功")
+        void shouldUnbindDevice() throws Exception {
+            doNothing().when(replenisherService).unbindDevice(1L, "DEV001");
+
+            mockMvc.perform(delete("/replenishers/1/devices/DEV001"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("解绑成功"));
+
+            verify(replenisherService).unbindDevice(1L, "DEV001");
+        }
+    }
+
+    // ============================================================
+    // 11. 批量绑定 POST /replenishers/batch-bind
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenishers/batch-bind - 批量绑定")
+    class BatchBindTests {
+
+        @Test
+        @DisplayName("批量绑定成功")
+        void shouldBatchBind() throws Exception {
+            BatchBindDevicesDTO dto = new BatchBindDevicesDTO();
+            dto.setReplenisherIds(Arrays.asList(1L, 2L, 3L));
+            dto.setDeviceIds(Arrays.asList("DEV001", "DEV002"));
+
+            when(replenisherService.batchBindDevices(anyList(), anyList())).thenReturn(6);
+
+            mockMvc.perform(post("/replenishers/batch-bind")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("批量绑定完成"))
+                    .andExpect(jsonPath("$.data.success").value(6))
+                    .andExpect(jsonPath("$.data.total").value(6));
+
+            verify(replenisherService).batchBindDevices(
+                    eq(Arrays.asList(1L, 2L, 3L)),
+                    eq(Arrays.asList("DEV001", "DEV002")));
+        }
+
+        @Test
+        @DisplayName("批量绑定部分成功")
+        void shouldPartialBatchBind() throws Exception {
+            BatchBindDevicesDTO dto = new BatchBindDevicesDTO();
+            dto.setReplenisherIds(Arrays.asList(1L, 2L));
+            dto.setDeviceIds(Collections.singletonList("DEV001"));
+
+            when(replenisherService.batchBindDevices(anyList(), anyList())).thenReturn(1);
+
+            mockMvc.perform(post("/replenishers/batch-bind")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.success").value(1))
+                    .andExpect(jsonPath("$.data.total").value(2));
+        }
+
+        @Test
+        @DisplayName("空列表批量绑定")
+        void shouldHandleEmptyBatchBind() throws Exception {
+            BatchBindDevicesDTO dto = new BatchBindDevicesDTO();
+            dto.setReplenisherIds(Collections.emptyList());
+            dto.setDeviceIds(Collections.emptyList());
+
+            when(replenisherService.batchBindDevices(anyList(), anyList())).thenReturn(0);
+
+            mockMvc.perform(post("/replenishers/batch-bind")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.success").value(0))
+                    .andExpect(jsonPath("$.data.total").value(0));
+        }
+    }
+
+    // ============================================================
+    // 12. 生成绑定码 POST /replenishers/{id}/binding-code
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenishers/{id}/binding-code - 生成绑定码")
+    class GenerateBindingCodeTests {
+
+        @Test
+        @DisplayName("生成成功")
+        void shouldGenerateBindingCode() throws Exception {
+            String expectedCode = "A1B2-C3D4-E5F6";
+            when(replenisherService.generateBindingCode(1L)).thenReturn(expectedCode);
+
+            mockMvc.perform(post("/replenishers/1/binding-code"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("生成绑定码成功"))
+                    .andExpect(jsonPath("$.data.bindingCode").value(expectedCode))
+                    .andExpect(jsonPath("$.data.replenisherId").value("1"));
+
+            verify(replenisherService).generateBindingCode(1L);
+        }
+
+        @Test
+        @DisplayName("为不同补货员生成不同绑定码")
+        void shouldGenerateDifferentCodesForDifferentReplenishers() throws Exception {
+            when(replenisherService.generateBindingCode(1L)).thenReturn("CODE-AAAA");
+            when(replenisherService.generateBindingCode(2L)).thenReturn("CODE-BBBB");
+
+            mockMvc.perform(post("/replenishers/1/binding-code"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.bindingCode").value("CODE-AAAA"));
+
+            mockMvc.perform(post("/replenishers/2/binding-code"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.bindingCode").value("CODE-BBBB"));
+
+            verify(replenisherService).generateBindingCode(1L);
+            verify(replenisherService).generateBindingCode(2L);
+        }
+    }
+
+    // ============================================================
+    // 综合场景测试
+    // ============================================================
+
+    @Nested
+    @DisplayName("综合场景 - 补货员完整生命周期")
+    class LifecycleTests {
+
+        @Test
+        @DisplayName("创建 → 查询 → 更新 → 禁用 → 删除")
+        void shouldCompleteLifecycle() throws Exception {
+            // 1. 创建
+            ReplenisherCreateDTO createDto = new ReplenisherCreateDTO();
+            createDto.setName("生命周期测试");
+            createDto.setPhone("13500135000");
+
+            Replenisher created = createTestReplenisher(100L);
+            created.setName("生命周期测试");
+            created.setPhone("13500135000");
+
+            when(replenisherService.createReplenisher(any(ReplenisherCreateDTO.class)))
+                    .thenReturn(created);
+
+            mockMvc.perform(post("/replenishers")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(createDto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.id").value(100));
+
+            // 2. 查询
+            when(replenisherService.getReplenisherDetail(100L)).thenReturn(created);
+
+            mockMvc.perform(get("/replenishers/100"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.name").value("生命周期测试"));
+
+            // 3. 更新
+            ReplenisherUpdateDTO updateDto = new ReplenisherUpdateDTO();
+            updateDto.setName("已更新");
+
+            Replenisher updated = createTestReplenisher(100L);
+            updated.setName("已更新");
+
+            when(replenisherService.updateReplenisher(eq(100L), any(ReplenisherUpdateDTO.class)))
+                    .thenReturn(updated);
+
+            mockMvc.perform(put("/replenishers/100")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(updateDto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.name").value("已更新"));
+
+            // 4. 禁用
+            StatusDTO statusDto = new StatusDTO();
+            statusDto.setStatus(0);
+
+            doNothing().when(replenisherService).updateStatus(100L, 0);
+
+            mockMvc.perform(put("/replenishers/100/status")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(statusDto)))
+                    .andExpect(status().isOk());
+
+            // 5. 删除
+            doNothing().when(replenisherService).deleteReplenisher(100L);
+
+            mockMvc.perform(delete("/replenishers/100"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.message").value("删除成功"));
+        }
+    }
+}

+ 199 - 0
haha-admin/src/test/java/com/haha/admin/controller/ReplenisherLoginControllerTest.java

@@ -0,0 +1,199 @@
+package com.haha.admin.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.haha.common.vo.LoginVO;
+import com.haha.common.vo.Result;
+import com.haha.common.vo.UserVO;
+import com.haha.entity.dto.ReplenisherBindDTO;
+import com.haha.entity.dto.WechatLoginDTO;
+import com.haha.service.ReplenisherService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 补货员登录控制器 - 完整单元测试
+ * <p>
+ * 覆盖微信静默登录和扫码绑定两个接口
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("补货员登录控制器")
+class ReplenisherLoginControllerTest {
+
+    @Mock
+    private ReplenisherService replenisherService;
+
+    @InjectMocks
+    private ReplenisherLoginController controller;
+
+    private MockMvc mockMvc;
+    private ObjectMapper objectMapper;
+
+    @BeforeEach
+    void setUp() {
+        objectMapper = new ObjectMapper();
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ============================================================
+    // 1. POST /replenisher/login/wechat - 微信静默登录
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenisher/login/wechat - 微信静默登录")
+    class WechatLoginTests {
+
+        @Test
+        @DisplayName("已绑定微信的补货员登录成功")
+        void shouldLoginSuccessfully() throws Exception {
+            WechatLoginDTO dto = new WechatLoginDTO();
+            dto.setCode("test-wechat-code-123");
+
+            UserVO userInfo = UserVO.builder()
+                    .id(1L)
+                    .nickname("测试补货员")
+                    .phone("13800138000")
+                    .build();
+
+            LoginVO loginVO = LoginVO.builder()
+                    .token("test-token-xxx")
+                    .userInfo(userInfo)
+                    .build();
+
+            Result<LoginVO> successResult = Result.success("登录成功", loginVO);
+            when(replenisherService.loginByWechat("test-wechat-code-123"))
+                    .thenReturn(successResult);
+
+            mockMvc.perform(post("/replenisher/login/wechat")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("登录成功"))
+                    .andExpect(jsonPath("$.data.token").value("test-token-xxx"))
+                    .andExpect(jsonPath("$.data.userInfo.nickname").value("测试补货员"));
+
+            verify(replenisherService).loginByWechat("test-wechat-code-123");
+        }
+
+        @Test
+        @DisplayName("未绑定微信的补货员登录失败")
+        void shouldFailWhenWechatNotBound() throws Exception {
+            WechatLoginDTO dto = new WechatLoginDTO();
+            dto.setCode("unbound-wechat-code");
+
+            Result<LoginVO> errorResult = Result.error(400, "该微信未绑定补货员账号");
+            when(replenisherService.loginByWechat("unbound-wechat-code"))
+                    .thenReturn(errorResult);
+
+            mockMvc.perform(post("/replenisher/login/wechat")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(400))
+                    .andExpect(jsonPath("$.message").value("该微信未绑定补货员账号"));
+
+            verify(replenisherService).loginByWechat("unbound-wechat-code");
+        }
+    }
+
+    // ============================================================
+    // 2. POST /replenisher/login/bind - 扫码绑定微信
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenisher/login/bind - 扫码绑定微信")
+    class BindWechatTests {
+
+        @Test
+        @DisplayName("绑定成功")
+        void shouldBindSuccessfully() throws Exception {
+            ReplenisherBindDTO dto = new ReplenisherBindDTO();
+            dto.setBindingCode("A1B2-C3D4-E5F6-G7H8");
+            dto.setCode("wechat-code-from-wx-login");
+
+            UserVO userInfo = UserVO.builder()
+                    .id(2L)
+                    .nickname("新补货员")
+                    .build();
+
+            LoginVO loginVO = LoginVO.builder()
+                    .token("bind-token-xxx")
+                    .userInfo(userInfo)
+                    .build();
+
+            Result<LoginVO> successResult = Result.success("绑定成功", loginVO);
+            when(replenisherService.bindWechat(eq("A1B2-C3D4-E5F6-G7H8"), eq("wechat-code-from-wx-login")))
+                    .thenReturn(successResult);
+
+            mockMvc.perform(post("/replenisher/login/bind")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.message").value("绑定成功"))
+                    .andExpect(jsonPath("$.data.token").value("bind-token-xxx"))
+                    .andExpect(jsonPath("$.data.userInfo.nickname").value("新补货员"));
+
+            verify(replenisherService).bindWechat("A1B2-C3D4-E5F6-G7H8", "wechat-code-from-wx-login");
+        }
+
+        @Test
+        @DisplayName("绑定码无效")
+        void shouldFailWhenBindingCodeInvalid() throws Exception {
+            ReplenisherBindDTO dto = new ReplenisherBindDTO();
+            dto.setBindingCode("INVALID-CODE");
+            dto.setCode("wechat-code-xxx");
+
+            Result<LoginVO> errorResult = Result.error(400, "绑定码无效或已过期");
+            when(replenisherService.bindWechat(eq("INVALID-CODE"), eq("wechat-code-xxx")))
+                    .thenReturn(errorResult);
+
+            mockMvc.perform(post("/replenisher/login/bind")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(400))
+                    .andExpect(jsonPath("$.message").value("绑定码无效或已过期"));
+
+            verify(replenisherService).bindWechat("INVALID-CODE", "wechat-code-xxx");
+        }
+
+        @Test
+        @DisplayName("绑定码已被使用")
+        void shouldFailWhenBindingCodeAlreadyUsed() throws Exception {
+            ReplenisherBindDTO dto = new ReplenisherBindDTO();
+            dto.setBindingCode("USED-CODE-1234");
+            dto.setCode("wechat-code-xxx");
+
+            Result<LoginVO> errorResult = Result.error(400, "绑定码已被使用");
+            when(replenisherService.bindWechat(eq("USED-CODE-1234"), eq("wechat-code-xxx")))
+                    .thenReturn(errorResult);
+
+            mockMvc.perform(post("/replenisher/login/bind")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(400))
+                    .andExpect(jsonPath("$.message").value("绑定码已被使用"));
+
+            verify(replenisherService).bindWechat("USED-CODE-1234", "wechat-code-xxx");
+        }
+    }
+}

+ 396 - 0
haha-admin/src/test/java/com/haha/admin/controller/ReplenisherOperationControllerTest.java

@@ -0,0 +1,396 @@
+package com.haha.admin.controller;
+
+import cn.dev33.satoken.session.SaSession;
+import cn.dev33.satoken.stp.StpUtil;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.haha.entity.Device;
+import com.haha.entity.DeviceInventory;
+import com.haha.entity.Replenisher;
+import com.haha.entity.dto.ReplenishDTO;
+import com.haha.service.DeviceInventoryService;
+import com.haha.service.DeviceService;
+import com.haha.service.ReplenisherService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.*;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+/**
+ * 补货员操作控制器 - 完整单元测试
+ * <p>
+ * 覆盖补货员端接口:个人信息、设备列表、库存查询、补货操作
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("补货员操作控制器")
+class ReplenisherOperationControllerTest {
+
+    @Mock
+    private ReplenisherService replenisherService;
+    @Mock
+    private DeviceInventoryService deviceInventoryService;
+    @Mock
+    private DeviceService deviceService;
+
+    @InjectMocks
+    private ReplenisherOperationController controller;
+
+    private MockMvc mockMvc;
+    private ObjectMapper objectMapper;
+    private MockedStatic<StpUtil> stpUtilMock;
+
+    private Replenisher mockReplenisher;
+
+    @BeforeEach
+    void setUp() {
+        objectMapper = new ObjectMapper();
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+
+        mockReplenisher = new Replenisher();
+        mockReplenisher.setId(1L);
+        mockReplenisher.setName("测试补货员");
+        mockReplenisher.setPhone("13800138000");
+        mockReplenisher.setEmployeeId("EMP001");
+        mockReplenisher.setStatus(1);
+
+        // 模拟补货员登录态
+        SaSession mockSession = mock(SaSession.class);
+        when(mockSession.getString("userType")).thenReturn("REPLENISHER");
+
+        stpUtilMock = mockStatic(StpUtil.class);
+        stpUtilMock.when(StpUtil::getTokenSession).thenReturn(mockSession);
+        stpUtilMock.when(StpUtil::getLoginIdAsString).thenReturn("1");
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (stpUtilMock != null) {
+            stpUtilMock.close();
+        }
+    }
+
+    // ============================================================
+    // 1. GET /replenisher/my-info
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenisher/my-info - 获取当前补货员信息")
+    class MyInfoTests {
+
+        @Test
+        @DisplayName("成功获取补货员信息")
+        void shouldGetMyInfo() throws Exception {
+            mockReplenisher.setBoundDeviceCount(3);
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getReplenisherDetail(1L)).thenReturn(mockReplenisher);
+
+            mockMvc.perform(get("/replenisher/my-info"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.name").value("测试补货员"))
+                    .andExpect(jsonPath("$.data.phone").value("13800138000"))
+                    .andExpect(jsonPath("$.data.boundDeviceCount").value(3));
+
+            verify(replenisherService).getReplenisherDetail(1L);
+        }
+
+        @Test
+        @DisplayName("补货员不存在")
+        void shouldReturn404WhenReplenisherNotFound() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getReplenisherDetail(1L)).thenReturn(null);
+
+            mockMvc.perform(get("/replenisher/my-info"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(404))
+                    .andExpect(jsonPath("$.message").value("补货员不存在"));
+        }
+    }
+
+    // ============================================================
+    // 2. GET /replenisher/device/list
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenisher/device/list - 获取绑定设备列表")
+    class DeviceListTests {
+
+        @Test
+        @DisplayName("有绑定设备时返回设备及库存概览")
+        void shouldReturnDeviceListWithInventory() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Arrays.asList("DEV001", "DEV002"));
+
+            Device dev1 = new Device();
+            dev1.setDeviceId("DEV001");
+            dev1.setName("设备一");
+            dev1.setStatus(1);
+            dev1.setAddress("一号门店");
+
+            Device dev2 = new Device();
+            dev2.setDeviceId("DEV002");
+            dev2.setName("设备二");
+            dev2.setStatus(0);
+            dev2.setAddress("二号门店");
+
+            when(deviceService.getDeviceBySn("DEV001")).thenReturn(dev1);
+            when(deviceService.getDeviceBySn("DEV002")).thenReturn(dev2);
+
+            // 设备一的库存:正常
+            Map<String, Object> inv1_1 = new HashMap<>();
+            inv1_1.put("stock", 10);
+            inv1_1.put("warning_threshold", 5);
+            List<Map<String, Object>> invList1 = Collections.singletonList(inv1_1);
+
+            // 设备二的库存:缺货+低库存
+            Map<String, Object> inv2_1 = new HashMap<>();
+            inv2_1.put("stock", 0);
+            inv2_1.put("warning_threshold", 5);
+            Map<String, Object> inv2_2 = new HashMap<>();
+            inv2_2.put("stock", 3);
+            inv2_2.put("warning_threshold", 10);
+            List<Map<String, Object>> invList2 = Arrays.asList(inv2_1, inv2_2);
+
+            when(deviceInventoryService.getInventoryWithProduct("DEV001")).thenReturn(invList1);
+            when(deviceInventoryService.getInventoryWithProduct("DEV002")).thenReturn(invList2);
+
+            mockMvc.perform(get("/replenisher/device/list"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.length()").value(2))
+                    // 设备一
+                    .andExpect(jsonPath("$.data[0].deviceId").value("DEV001"))
+                    .andExpect(jsonPath("$.data[0].name").value("设备一"))
+                    .andExpect(jsonPath("$.data[0].status").value(1))
+                    .andExpect(jsonPath("$.data[0].statusLabel").value("在线"))
+                    .andExpect(jsonPath("$.data[0].totalStock").value(10))
+                    .andExpect(jsonPath("$.data[0].lowStockCount").value(0))
+                    .andExpect(jsonPath("$.data[0].zeroStockCount").value(0))
+                    .andExpect(jsonPath("$.data[0].productCount").value(1))
+                    // 设备二
+                    .andExpect(jsonPath("$.data[1].deviceId").value("DEV002"))
+                    .andExpect(jsonPath("$.data[1].statusLabel").value("离线"))
+                    .andExpect(jsonPath("$.data[1].totalStock").value(3))
+                    .andExpect(jsonPath("$.data[1].lowStockCount").value(1))
+                    .andExpect(jsonPath("$.data[1].zeroStockCount").value(1))
+                    .andExpect(jsonPath("$.data[1].productCount").value(2));
+        }
+
+        @Test
+        @DisplayName("无绑定设备时返回空列表")
+        void shouldReturnEmptyWhenNoDevices() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.emptyList());
+
+            mockMvc.perform(get("/replenisher/device/list"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.length()").value(0));
+        }
+    }
+
+    // ============================================================
+    // 3. GET /replenisher/device/inventory/{deviceId}
+    // ============================================================
+
+    @Nested
+    @DisplayName("GET /replenisher/device/inventory/{deviceId} - 获取设备库存详情")
+    class DeviceInventoryTests {
+
+        @Test
+        @DisplayName("成功获取设备库存详情")
+        void shouldGetDeviceInventory() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.singletonList("DEV001"));
+
+            Device device = new Device();
+            device.setDeviceId("DEV001");
+            device.setName("测试设备");
+            device.setAddress("测试地址");
+            when(deviceService.getDeviceBySn("DEV001")).thenReturn(device);
+
+            when(deviceInventoryService.getInventoryWithProduct("DEV001"))
+                    .thenReturn(Collections.emptyList());
+
+            mockMvc.perform(get("/replenisher/device/inventory/DEV001"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.deviceId").value("DEV001"))
+                    .andExpect(jsonPath("$.data.name").value("测试设备"))
+                    .andExpect(jsonPath("$.data.address").value("测试地址"))
+                    .andExpect(jsonPath("$.data.inventoryList.length()").value(0));
+        }
+
+        @Test
+        @DisplayName("无权查看未绑定设备")
+        void shouldReturn403WhenDeviceNotBound() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.singletonList("DEV001")); // 只绑定了DEV001
+
+            mockMvc.perform(get("/replenisher/device/inventory/DEV999"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(403))
+                    .andExpect(jsonPath("$.message").value("您无权查看此设备的库存"));
+        }
+
+        @Test
+        @DisplayName("设备不存在")
+        void shouldReturn404WhenDeviceNotFound() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.singletonList("DEV001"));
+            when(deviceService.getDeviceBySn("DEV001")).thenReturn(null);
+
+            mockMvc.perform(get("/replenisher/device/inventory/DEV001"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(404))
+                    .andExpect(jsonPath("$.message").value("设备不存在"));
+        }
+    }
+
+    // ============================================================
+    // 4. POST /replenisher/stock/replenish
+    // ============================================================
+
+    @Nested
+    @DisplayName("POST /replenisher/stock/replenish - 执行补货操作")
+    class ReplenishStockTests {
+
+        @Test
+        @DisplayName("补货全部成功")
+        void shouldReplenishAllSuccess() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.singletonList("DEV001"));
+
+            ReplenishDTO dto = new ReplenishDTO();
+            dto.setDeviceId("DEV001");
+
+            ReplenishDTO.ReplenishItem item1 = new ReplenishDTO.ReplenishItem();
+            item1.setProductId(1L);
+            item1.setProductCode("P001");
+            item1.setProductName("商品一");
+            item1.setQuantity(5);
+            item1.setShelfNum(1);
+            item1.setPosition("A1");
+
+            ReplenishDTO.ReplenishItem item2 = new ReplenishDTO.ReplenishItem();
+            item2.setProductId(2L);
+            item2.setProductCode("P002");
+            item2.setProductName("商品二");
+            item2.setQuantity(3);
+            item2.setShelfNum(1);
+            item2.setPosition("A2");
+
+            dto.setItems(Arrays.asList(item1, item2));
+
+            DeviceInventory inv1 = new DeviceInventory();
+            inv1.setStock(15);
+            DeviceInventory inv2 = new DeviceInventory();
+            inv2.setStock(8);
+
+            when(deviceInventoryService.increaseStock(
+                    eq("DEV001"), eq(1L), eq("P001"), eq("商品一"),
+                    eq(5), eq(1), eq("A1"), eq(1L), eq("测试补货员"), isNull()))
+                    .thenReturn(inv1);
+            when(deviceInventoryService.increaseStock(
+                    eq("DEV001"), eq(2L), eq("P002"), eq("商品二"),
+                    eq(3), eq(1), eq("A2"), eq(1L), eq("测试补货员"), isNull()))
+                    .thenReturn(inv2);
+
+            mockMvc.perform(post("/replenisher/stock/replenish")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.total").value(2))
+                    .andExpect(jsonPath("$.data.success").value(2))
+                    .andExpect(jsonPath("$.data.details.length()").value(2))
+                    .andExpect(jsonPath("$.data.details[0].success").value(true))
+                    .andExpect(jsonPath("$.data.details[1].success").value(true));
+        }
+
+        @Test
+        @DisplayName("补货部分失败")
+        void shouldReplenishPartialFailure() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.singletonList("DEV001"));
+
+            ReplenishDTO dto = new ReplenishDTO();
+            dto.setDeviceId("DEV001");
+
+            ReplenishDTO.ReplenishItem item1 = new ReplenishDTO.ReplenishItem();
+            item1.setProductId(1L);
+            item1.setProductName("商品一");
+            item1.setQuantity(5);
+
+            ReplenishDTO.ReplenishItem item2 = new ReplenishDTO.ReplenishItem();
+            item2.setProductId(2L);
+            item2.setProductName("商品二");
+            item2.setQuantity(3);
+
+            dto.setItems(Arrays.asList(item1, item2));
+
+            DeviceInventory inv1 = new DeviceInventory();
+            inv1.setStock(5);
+
+            when(deviceInventoryService.increaseStock(
+                    eq("DEV001"), eq(1L), isNull(), eq("商品一"),
+                    eq(5), isNull(), isNull(), eq(1L), eq("测试补货员"), isNull()))
+                    .thenReturn(inv1);
+            when(deviceInventoryService.increaseStock(
+                    eq("DEV001"), eq(2L), isNull(), eq("商品二"),
+                    eq(3), isNull(), isNull(), eq(1L), eq("测试补货员"), isNull()))
+                    .thenThrow(new RuntimeException("库存不足"));
+
+            mockMvc.perform(post("/replenisher/stock/replenish")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(200))
+                    .andExpect(jsonPath("$.data.total").value(2))
+                    .andExpect(jsonPath("$.data.success").value(1))
+                    .andExpect(jsonPath("$.data.details[0].success").value(true))
+                    .andExpect(jsonPath("$.data.details[1].success").value(false))
+                    .andExpect(jsonPath("$.data.details[1].error").value("库存不足"));
+        }
+
+        @Test
+        @DisplayName("无权对未绑定设备补货")
+        void shouldReturn403WhenDeviceNotBound() throws Exception {
+            when(replenisherService.getById("1")).thenReturn(mockReplenisher);
+            when(replenisherService.getBoundDeviceIds(1L))
+                    .thenReturn(Collections.singletonList("DEV001")); // 只绑定DEV001
+
+            ReplenishDTO dto = new ReplenishDTO();
+            dto.setDeviceId("DEV999");
+            dto.setItems(Collections.emptyList());
+
+            mockMvc.perform(post("/replenisher/stock/replenish")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(objectMapper.writeValueAsString(dto)))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.code").value(403))
+                    .andExpect(jsonPath("$.message").value("您无权对此设备进行补货操作"));
+        }
+    }
+}

+ 25 - 0
haha-miniapp/src/main/java/com/haha/miniapp/controller/PayScoreController.java

@@ -134,6 +134,31 @@ public class PayScoreController {
         }
     }
 
+    /**
+     * 查询支付分服务订单状态(按 outOrderNo 查询)
+     * 用于微信支付分授权返回后,判断用户是确认还是取消
+     */
+    @GetMapping("/query-out-no/{outOrderNo}")
+    public Result<Map<String, Object>> queryByOutOrderNo(@PathVariable String outOrderNo) {
+        try {
+            log.info("按 outOrderNo 查询支付分状态 - outOrderNo: {}", outOrderNo);
+
+            PayScoreResult result = payScoreService.queryPayScoreOrder(outOrderNo);
+            if (result.isSuccess()) {
+                Map<String, Object> data = new java.util.HashMap<>();
+                data.put("outOrderNo", result.getOutOrderNo());
+                data.put("state", result.getState());
+                log.info("查询成功 - outOrderNo: {}, state: {}", outOrderNo, result.getState());
+                return Result.success(data);
+            } else {
+                return Result.error(result.getErrorMsg());
+            }
+        } catch (Exception e) {
+            log.error("查询支付分服务订单失败", e);
+            return Result.error("查询失败:" + e.getMessage());
+        }
+    }
+
     /**
      * 查询支付分服务订单状态
      */

+ 7 - 0
haha-mp/src/api/payscore.ts

@@ -83,3 +83,10 @@ export const refundPayscoreOrder = (orderId: number, params: RefundPayscoreOrder
 export const queryPayscoreRefund = (orderId: number, outRefundNo: string): Promise<{ outOrderNo: string; status: string; amount: number }> => {
   return get<{ outOrderNo: string; status: string; amount: number }>(API_PATHS.queryPayscoreRefund(orderId, outRefundNo));
 };
+
+/**
+ * 按 outOrderNo 查询支付分订单状态(授权返回后判断用户确认还是取消)
+ */
+export const queryPayscoreByOutNo = (outOrderNo: string): Promise<{ outOrderNo: string; state: string }> => {
+  return get<{ outOrderNo: string; state: string }>(API_PATHS.queryPayscoreByOutNo(outOrderNo), undefined, { silent: true });
+};

+ 67 - 9
haha-mp/src/pages/index/index.vue

@@ -79,7 +79,7 @@
 import { ref } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 import { scanDoor } from '../../api/device'
-import { preCreatePayscoreOrder } from '../../api/payscore'
+import { preCreatePayscoreOrder, queryPayscoreByOutNo } from '../../api/payscore'
 import { isLoggedIn, getToken } from '../../utils/auth'
 import { logger } from '../../utils/logger'
 
@@ -87,6 +87,8 @@ const authReady = ref(false)
 const authBusy = ref(false)
 const authDeviceId = ref('')
 const authPackage = ref('')
+const authOutOrderNo = ref('')
+let pollTimer: ReturnType<typeof setInterval> | null = null
 
 onShow(() => {
   const token = getToken()
@@ -160,6 +162,7 @@ const prepareAuth = async (deviceId: string) => {
 
     authDeviceId.value = deviceId;
     authPackage.value = result.package;
+    authOutOrderNo.value = result.outOrderNo;
     authReady.value = true;
   } catch (error: any) {
     uni.hideLoading();
@@ -199,17 +202,16 @@ const invokePayScore = () => {
       package: pkg,
     },
     success: () => {
-      authBusy.value = false;
-      authReady.value = false;
-      logger.log('[支付分] 确认成功,开门');
-      doOpenDoor(authDeviceId.value);
+      // 用户从支付分页面返回,但不确定是确认还是取消
+      // 轮询后端查支付分订单状态来判断
+      logger.log('[支付分] 从支付分页面返回,开始轮询确认状态');
+      pollPayScoreStatus();
     },
     fail: (err: any) => {
       authBusy.value = false;
       const errMsg = err?.errMsg || JSON.stringify(err);
       logger.error('[支付分] openBusinessView fail:', errMsg);
 
-      // 用户取消不弹窗
       if (errMsg.includes('cancel')) {
         return;
       }
@@ -220,13 +222,69 @@ const invokePayScore = () => {
         showCancel: false,
       });
     },
-    complete: () => {
-      logger.log('[支付分] openBusinessView complete');
-    },
   });
 };
 
+/**
+ * 轮询支付分订单状态,判断用户确认还是取消
+ * DOING = 用户已确认,CREATED = 用户取消/未操作
+ */
+const pollPayScoreStatus = () => {
+  uni.showLoading({ title: '确认结果...', mask: true });
+
+  const outOrderNo = authOutOrderNo.value;
+  let attempts = 0;
+  const maxAttempts = 10;
+  const interval = 1000; // 1s
+
+  if (pollTimer) clearInterval(pollTimer);
+
+  pollTimer = setInterval(async () => {
+    attempts++;
+    try {
+      const res = await queryPayscoreByOutNo(outOrderNo);
+      const state = res?.state;
+
+      logger.log(`[支付分] 轮询第 ${attempts} 次, state:`, state);
+
+      if (state === 'DOING') {
+        // 用户已确认
+        clearPollTimer();
+        uni.hideLoading();
+        authReady.value = false;
+        authBusy.value = false;
+        logger.log('[支付分] 用户已确认,开始开门');
+        doOpenDoor(authDeviceId.value);
+        return;
+      }
+
+      if (attempts >= maxAttempts) {
+        // 超时未确认,视为取消
+        clearPollTimer();
+        uni.hideLoading();
+        authBusy.value = false;
+        logger.log('[支付分] 轮询超时,用户未确认');
+      }
+    } catch (e) {
+      logger.error('[支付分] 轮询异常', e);
+      if (attempts >= maxAttempts) {
+        clearPollTimer();
+        uni.hideLoading();
+        authBusy.value = false;
+      }
+    }
+  }, interval);
+};
+
+const clearPollTimer = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  }
+};
+
 const cancelAuth = () => {
+  clearPollTimer();
   authReady.value = false;
   authBusy.value = false;
   logger.log('[支付分] 用户取消,返回扫码');

+ 51 - 5
haha-mp/src/pages/products/products.vue

@@ -121,7 +121,7 @@
 import { ref, computed } from 'vue';
 import { onLoad } from '@dcloudio/uni-app';
 import { getDeviceProducts, scanDoor } from '@/api/device';
-import { preCreatePayscoreOrder } from '@/api/payscore';
+import { preCreatePayscoreOrder, queryPayscoreByOutNo } from '@/api/payscore';
 import { isLoggedIn } from '@/utils/auth';
 import { logger } from '@/utils/logger';
 import BrandSlogan from '@/components/BrandSlogan.vue';
@@ -142,6 +142,8 @@ const opening = ref(false);
 const authReady = ref(false);
 const authBusy = ref(false);
 const authPackage = ref('');
+const authOutOrderNo = ref('');
+let pollTimer: ReturnType<typeof setInterval> | null = null;
 
 const activeDoor = ref<'left' | 'right'>('left');
 
@@ -237,6 +239,7 @@ const handleOpenDoor = async () => {
 
     logger.log('[商品页] 支付分预创建成功');
     authPackage.value = result.package;
+    authOutOrderNo.value = result.outOrderNo;
     authReady.value = true;
   } catch (error: any) {
     uni.hideLoading();
@@ -268,10 +271,8 @@ const invokePayScore = () => {
       package: pkg,
     },
     success: () => {
-      authBusy.value = false;
-      authReady.value = false;
-      logger.log('[商品页] 支付分确认成功,开门');
-      doOpenDoor();
+      logger.log('[商品页] 从支付分页面返回,开始轮询确认状态');
+      pollPayScoreStatus();
     },
     fail: (err: any) => {
       authBusy.value = false;
@@ -288,6 +289,51 @@ const invokePayScore = () => {
   });
 };
 
+const pollPayScoreStatus = () => {
+  uni.showLoading({ title: '确认结果...', mask: true });
+
+  const outOrderNo = authOutOrderNo.value;
+  let attempts = 0;
+  const maxAttempts = 10;
+  const interval = 1000;
+
+  if (pollTimer) clearInterval(pollTimer);
+
+  pollTimer = setInterval(async () => {
+    attempts++;
+    try {
+      const res = await queryPayscoreByOutNo(outOrderNo);
+      const state = res?.state;
+
+      logger.log(`[商品页] 轮询第 ${attempts} 次, state:`, state);
+
+      if (state === 'DOING') {
+        if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+        uni.hideLoading();
+        authReady.value = false;
+        authBusy.value = false;
+        logger.log('[商品页] 用户已确认,开始开门');
+        doOpenDoor();
+        return;
+      }
+
+      if (attempts >= maxAttempts) {
+        if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+        uni.hideLoading();
+        authBusy.value = false;
+        logger.log('[商品页] 轮询超时,用户未确认');
+      }
+    } catch (e) {
+      logger.error('[商品页] 轮询异常', e);
+      if (attempts >= maxAttempts) {
+        if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+        uni.hideLoading();
+        authBusy.value = false;
+      }
+    }
+  }, interval);
+};
+
 const doOpenDoor = async () => {
   uni.showLoading({ title: '正在开门...', mask: true });
 

+ 1 - 0
haha-mp/src/utils/config.ts

@@ -53,6 +53,7 @@ export const API_PATHS = {
   syncPayscoreOrderInfo: (orderId: number) => `/payscore/sync-info/${orderId}`,
   refundPayscoreOrder: (orderId: number) => `/payscore/refund/${orderId}`,
   queryPayscoreRefund: (orderId: number, outRefundNo: string) => `/payscore/refund/${orderId}/${outRefundNo}`,
+  queryPayscoreByOutNo: (outOrderNo: string) => `/payscore/query-out-no/${outOrderNo}`,
   
   // 优惠券相关
   getMyCoupons: '/coupon/my',

+ 17 - 0
skills-lock.json

@@ -0,0 +1,17 @@
+{
+  "version": 1,
+  "skills": {
+    "wechatpay-payment-integration": {
+      "source": "wechatpay-apiv3/wechatpay-skills",
+      "sourceType": "github",
+      "skillPath": "wechatpay-payment-integration/SKILL.md",
+      "computedHash": "7205dddc138b20f42b91091df4b6ad8fd133d72d8ba98c3e21680f3e80a2f4ad"
+    },
+    "wechatpay-product-coupon": {
+      "source": "wechatpay-apiv3/wechatpay-skills",
+      "sourceType": "github",
+      "skillPath": "wechatpay-product-coupon/SKILL.md",
+      "computedHash": "d7636593202d8cd4ebc10e49c82d72a4c6bc18b6cc9f8c255110def3f890846a"
+    }
+  }
+}