Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

Sisyphus Agent 15 часов назад
Родитель
Сommit
fd2455983f
24 измененных файлов с 1710 добавлено и 26 удалено
  1. 66 13
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 266 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java
  3. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ComboSelectionDTO.java
  4. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ComboTierDTO.java
  5. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CustomizationStateDTO.java
  6. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProcurementTaskDTO.java
  7. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProcurementTask.java
  8. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProcurementTaskMapper.java
  9. 301 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProcurementTaskService.java
  10. 60 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  11. 24 0
      cfc-backend/src/main/resources/schema.sql
  12. 59 2
      cfc-desktop-pet/src-tauri/src/api/mod.rs
  13. 16 0
      cfc-desktop-pet/src-tauri/src/api/models.rs
  14. 3 0
      cfc-desktop-pet/src-tauri/src/app_state.rs
  15. 30 1
      cfc-desktop-pet/src-tauri/src/commands.rs
  16. 414 0
      cfc-desktop-pet/src-tauri/src/locomotion.rs
  17. 6 0
      cfc-desktop-pet/src-tauri/src/main.rs
  18. 53 0
      cfc-desktop-pet/src/animations.css
  19. 6 0
      cfc-desktop-pet/src/bridge.js
  20. 18 3
      cfc-desktop-pet/src/index.html
  21. 25 0
      cfc-desktop-pet/src/interactions.js
  22. 43 0
      cfc-desktop-pet/src/pet-pose.js
  23. 2 7
      cfc-desktop-pet/src/settings.html
  24. 118 0
      docs/superpowers/specs/2026-10-12-cockatiel-actions-design.md

+ 66 - 13
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -21,7 +21,6 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 @Component
@@ -95,18 +94,37 @@ public class DatabaseInitializer implements CommandLineRunner {
             // Remove SQL single-line comments
             content = content.replaceAll("(?m)^\\s*--.*$", "");
 
-            // Extract all CREATE TABLE IF NOT EXISTS statements
-            Pattern pattern = Pattern.compile(
-                    "CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+.*?;",
-                    Pattern.CASE_INSENSITIVE | Pattern.DOTALL
-            );
-            Matcher matcher = pattern.matcher(content);
-
+            // 提取所有 CREATE TABLE IF NOT EXISTS 语句。
+            // 不能用非贪婪 ".*?;" 匹配:COMMENT 字符串字面量中可能含分号(如
+            // task_reminder_config.offset_minutes 的注释 "截止前N分钟; after_timeout..."),
+            // 会被提前截断导致语句不完整而建表失败。此处按字符扫描,跳过引号内部的分号。
             List<String> createTableSQLs = new ArrayList<>();
-            while (matcher.find()) {
-                String sql = matcher.group().trim();
-                if (!sql.isEmpty()) {
-                    createTableSQLs.add(sql);
+            Pattern createTableHead = Pattern.compile(
+                    "^\\s*CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\b", Pattern.CASE_INSENSITIVE);
+            StringBuilder stmt = new StringBuilder();
+            boolean inSingleQuote = false;
+            boolean inBacktick = false;
+            for (int i = 0; i < content.length(); i++) {
+                char c = content.charAt(i);
+                if (c == '\'' && !inBacktick) {
+                    if (inSingleQuote && i + 1 < content.length() && content.charAt(i + 1) == '\'') {
+                        stmt.append("''");
+                        i++;
+                        continue;
+                    }
+                    inSingleQuote = !inSingleQuote;
+                    stmt.append(c);
+                } else if (c == '`' && !inSingleQuote) {
+                    inBacktick = !inBacktick;
+                    stmt.append(c);
+                } else if (c == ';' && !inSingleQuote && !inBacktick) {
+                    String sql = stmt.toString().trim();
+                    if (!sql.isEmpty() && createTableHead.matcher(sql).find()) {
+                        createTableSQLs.add(sql + ";");
+                    }
+                    stmt.setLength(0);
+                } else {
+                    stmt.append(c);
                 }
             }
 
@@ -2999,6 +3017,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createIndicatorCorrelationsTable();
         ensureIndicatorCollectionDate();
         seedDimensionDefinitions();
+
+        // 商品组合推荐与采购任务迁移(363)
+        createProcurementTaskTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -9191,7 +9212,8 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                         "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app'), " +
                         "('energy_change', '能量变动', '{description}获得{amount}能量', 'in_app'), " +
                         "('points_change', '积分变动', '{description}变动{amount}积分', 'in_app'), " +
-                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app')");
+                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app'), " +
+                        "('procurement_completed', '采购任务已完成', '您的【{comboTier}】套餐采购任务已完成,共{productCount}件商品,下单时间:{createTime},产品即将送达,请及时查收。', 'in_app,wechat')");
                 log.info("notification_templates 种子数据已插入");
             } else {
                 log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
@@ -12346,4 +12368,35 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void addProductVendorVisibleColumn() {
         ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可bypass)'");
     }
+
+    // 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+    private void createProcurementTaskTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS procurement_tasks (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识', " +
+                    "user_id BIGINT NOT NULL COMMENT '所属用户ID', " +
+                    "family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID', " +
+                    "combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low', " +
+                    "original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)', " +
+                    "selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)', " +
+                    "custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)', " +
+                    "custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)', " +
+                    "status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED', " +
+                    "create_time DATETIME NOT NULL COMMENT '创建时间', " +
+                    "complete_time DATETIME DEFAULT NULL COMMENT '完成时间', " +
+                    "message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒', " +
+                    "execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID', " +
+                    "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_task_id (task_id), " +
+                    "INDEX idx_user_id (user_id), " +
+                    "INDEX idx_family_member_id (family_member_id), " +
+                    "INDEX idx_status (status), " +
+                    "INDEX idx_create_time (create_time)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)'");
+            log.info("已创建procurement_tasks表");
+        } catch (Exception e) {
+            log.warn("创建procurement_tasks表失败: {}", e.getMessage());
+        }
+    }
 }

+ 266 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java

@@ -0,0 +1,266 @@
+package com.etotem.cfc.controller.product;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProcurementTaskService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * 商品组合推荐与采购任务控制器
+ * 提供三种方案(高/中/低端)+ 用户定制 + 采购任务全生命周期管理
+ * 路由前缀:/api/product/
+ */
+@RestController
+@RequestMapping("/api/product")
+public class ProcurementTaskController {
+
+    private static final Logger log = LoggerFactory.getLogger(ProcurementTaskController.class);
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private ProcurementTaskService procurementTaskService;
+
+    /**
+     * 获取三种商品组合方案(高/中/低端),从数据库按维度动态生成
+     */
+    @PostMapping("/combo-tiers")
+    public Result<List<ComboTierDTO>> getComboTiers(@RequestAttribute(value = "userId", required = false) Long userId) {
+        List<ComboTierDTO> tiers = new ArrayList<>();
+
+        // === 高端:五维全覆盖,价格区间较高 ===
+        Map<String, List<Product>> highProducts = productService.listByDimensions(
+                Arrays.asList("body", "mind", "wisdom", "action", "wealth"), 2);
+        List<Long> highSkuIds = buildSkuIdsFromProducts(highProducts);
+        tiers.add(buildTier("high", "尊享全家幸福包",
+                "涵盖身、智、富、行、心五大维度的完整幸福提案,精选优质产品,助您全家安享福贵双全",
+                highProducts, highSkuIds, 100000, 300000));
+
+        // === 中端:身+智+行,中等价位 ===
+        Map<String, List<Product>> mediumProducts = productService.listByDimensions(
+                Arrays.asList("body", "wisdom", "action"), 2);
+        List<Long> mediumSkuIds = buildSkuIdsFromProducts(mediumProducts);
+        tiers.add(buildTier("medium", "平衡成长包",
+                "精选2-3个核心维度的关键产品,性价比高,适合大多数家庭的起步配置",
+                mediumProducts, mediumSkuIds, 10000, 80000));
+
+        // === 低端:单一维度,入门价位 ===
+        List<Product> lowProducts = productService.listByDimension("body", null, null, 3);
+        List<Long> lowSkuIds = lowProducts.stream().map(p -> p.getId()).collect(java.util.stream.Collectors.toList());
+        tiers.add(buildTier("low", "基础启动包",
+                "单一维度必需品,入门首选,让您以最低成本体验核心关怀",
+                Collections.singletonMap("body", lowProducts), lowSkuIds, 0, 50000));
+
+        return Result.success(tiers);
+    }
+
+    /**
+     * 用户选择并确认套餐
+     */
+    @PostMapping("/select-combo")
+    public Result<ProcurementTaskDTO> selectCombo(@RequestBody ComboSelectionDTO selection,
+                                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                                   @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
+        if (selection.getSelectedTier() == null || !Arrays.asList("high", "medium", "low").contains(selection.getSelectedTier())) {
+            return Result.error("无效的套餐等级");
+        }
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        Long memberId = selection.getFamilyMemberId() != null ? selection.getFamilyMemberId() : familyMemberId;
+        if (memberId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+
+        ProcurementTaskDTO task = procurementTaskService.createTask(
+                userId, memberId, selection.getSelectedTier(),
+                selection.getSelectedSkuIds(), selection.getAddedSkuIds(), selection.getRemovedSkuIds());
+        return Result.success(task);
+    }
+
+    /**
+     * 获取当前定制状态
+     */
+    @PostMapping("/customization-state")
+    public Result<CustomizationStateDTO> getCustomizationState(
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
+        if (userId == null) return Result.error("请先登录");
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setUserId(userId);
+        state.setFamilyMemberId(familyMemberId);
+        state.setCurrentTier("low");
+        state.setBaseSkuIds(new ArrayList<>());
+        state.setAddedSkuIds(new ArrayList<>());
+        state.setRemovedSkuIds(new ArrayList<>());
+        state.setFinalSkuIds(new ArrayList<>());
+        state.setIsModified(false);
+        state.setLogEntries(new ArrayList<>());
+        return Result.success(state);
+    }
+
+    /**
+     * 添加产品到当前选择
+     */
+    @PostMapping("/add-product")
+    public Result<CustomizationStateDTO> addProduct(@RequestBody Map<String, Object> req,
+                                                     @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long skuId = req.get("skuId") instanceof Number ? ((Number) req.get("skuId")).longValue() : null;
+        if (skuId == null) {
+            try {
+                Object raw = req.get("skuId");
+                if (raw != null) skuId = Long.parseLong(raw.toString());
+            } catch (Exception ignored) {}
+        }
+        String skuName = req.get("skuName") != null ? req.get("skuName").toString() : "";
+        if (skuId == null) return Result.error("缺少有效的 SKU ID");
+
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setAddedSkuIds(new ArrayList<>(Collections.singletonList(skuId)));
+        state.setIsModified(true);
+        CustomizationStateDTO.CustomizationLogEntry entry = new CustomizationStateDTO.CustomizationLogEntry();
+        entry.setAction("add");
+        entry.setSkuId(skuId);
+        entry.setSkuName(skuName);
+        entry.setTimestamp(new Date());
+        state.setLogEntries(Collections.singletonList(entry));
+        return Result.success(state);
+    }
+
+    /**
+     * 从当前选择中移除产品
+     */
+    @PostMapping("/remove-product")
+    public Result<CustomizationStateDTO> removeProduct(@RequestBody Map<String, Object> req) {
+        Long skuId = req.get("skuId") instanceof Number ? ((Number) req.get("skuId")).longValue() : null;
+        if (skuId == null) {
+            try {
+                Object raw = req.get("skuId");
+                if (raw != null) skuId = Long.parseLong(raw.toString());
+            } catch (Exception ignored) {}
+        }
+        if (skuId == null) return Result.error("缺少有效的 SKU ID");
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setRemovedSkuIds(new ArrayList<>(Collections.singletonList(skuId)));
+        state.setIsModified(true);
+        return Result.success(state);
+    }
+
+    /**
+     * 启动采购流程(SELECTED → PROCURING → EXECUTING)
+     */
+    @PostMapping("/task/procure/{taskId}")
+    public Result<ProcurementTaskDTO> startProcurement(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.startProcurement(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 完成执行(EXECUTING → COMPLETED + 发送消息提醒)
+     */
+    @PostMapping("/task/execute/{taskId}")
+    public Result<ProcurementTaskDTO> completeExecution(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.completeExecution(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 取消任务
+     */
+    @PostMapping("/task/cancel/{taskId}")
+    public Result<ProcurementTaskDTO> cancelTask(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.cancelTask(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取任务详情
+     */
+    @PostMapping("/task-detail")
+    public Result<ProcurementTaskDTO> taskDetail(@RequestBody Map<String, Object> params) {
+        String taskId = params.get("taskId") != null ? params.get("taskId").toString() : null;
+        if (taskId == null) return Result.error("缺少任务ID");
+        ProcurementTaskDTO task = procurementTaskService.getTaskById(taskId);
+        return task != null ? Result.success(task) : Result.error("任务不存在");
+    }
+
+    /**
+     * 获取用户的采购任务列表
+     */
+    @PostMapping("/task-list")
+    public Result<List<ProcurementTaskDTO>> taskList(@RequestBody Map<String, Object> params,
+                                                      @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long queryUserId = params.get("userId") != null ?
+                ((Number) params.get("userId")).longValue() : userId;
+        return Result.success(procurementTaskService.getUserTasks(queryUserId));
+    }
+
+    /** ---------------------------------------------------------
+     *  内部辅助方法
+     *  --------------------------------------------------------- */
+
+    private ComboTierDTO buildTier(String tier, String title, String description,
+                                    Map<String, List<Product>> productMap,
+                                    List<Long> skuIds, int minPrice, int maxPrice) {
+        ComboTierDTO c = new ComboTierDTO();
+        c.setTier(tier);
+        c.setTitle(title);
+        c.setDescription(description);
+        c.setPrice(calculateComboPrice(productMap, skuIds));
+        c.setDimensions(new ArrayList<>(productMap.keySet()));
+        c.setBaseSkuIds(skuIds);
+        c.setCustomizable(true);
+        // 可添加/移除的品类名称
+        c.setAddableCategories(Arrays.asList("nutrition", "assessment", "workshop", "counseling", "membership"));
+        c.setRemovableCategories(new ArrayList<>(productMap.keySet()));
+        return c;
+    }
+
+    /**
+     * 从产品映射中提取实际 SKU ID 列表(取每个产品的第一个上架 SKU ID 或 productId 作为 base)
+     */
+    private List<Long> buildSkuIdsFromProducts(Map<String, List<Product>> productMap) {
+        List<Long> ids = new ArrayList<>();
+        for (List<Product> products : productMap.values()) {
+            if (products == null) continue;
+            for (Product p : products) {
+                if (p != null && p.getId() != null) {
+                    ids.add(p.getId());
+                }
+            }
+        }
+        return ids;
+    }
+
+    private int calculateComboPrice(Map<String, List<Product>> productMap, List<Long> skuIds) {
+        int total = 0;
+        if (productMap != null) {
+            for (List<Product> products : productMap.values()) {
+                if (products == null) continue;
+                for (Product p : products) {
+                    if (p.getPrice() != null) total += p.getPrice();
+                }
+            }
+        }
+        return Math.max(total, 1000); // 最低 10 元(DB 无数据兜底)
+    }
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ComboSelectionDTO.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class ComboSelectionDTO {
+    /** 用户选择的套餐等级 */
+    private String selectedTier; // high|medium|low
+    /** 是否进行了自定义 (add/remove) */
+    private Boolean customized;
+    /** 原始套餐等级 (customization时使用) */
+    private String originalTier;
+    /** 最终选中的商品 ID 列表 */
+    private List<Long> selectedSkuIds;
+    /** 用户新增的商品 ID 列表 */
+    private List<Long> addedSkuIds;
+    /** 用户移除的商品 ID 列表 */
+    private List<Long> removedSkuIds;
+    /** 当前登录用户ID */
+    private Long userId;
+    /** 当前家庭成员ID */
+    private Long familyMemberId;
+    /** 维度权重调整 (可选) */
+    private Map<String, Integer> dimensionWeights; // body,mind,wisdom,action,wealth
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ComboTierDTO.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class ComboTierDTO {
+    /** 套餐等级: high|medium|low */
+    private String tier;
+    /** 套餐标题 */
+    private String title;
+    /** 套餐描述 */
+    private String description;
+    /** 套餐价格(分) */
+    private Integer price;
+    /** 覆盖的维度列表 */
+    private List<String> dimensions;
+    /** 基础商品 ID 列表(对应 products.id) */
+    private List<Long> baseSkuIds;
+    /** 是否可定制 */
+    private Boolean customizable;
+    /** 可添加的产品类目名称 */
+    private List<String> addableCategories;
+    /** 可移除的产品维度码 */
+    private List<String> removableCategories;
+}

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CustomizationStateDTO.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class CustomizationStateDTO {
+    /** 当前选中的套餐等级 */
+    private String currentTier; // high|medium|low
+    /** 所属用户ID */
+    private Long userId;
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+    /** 基础套餐的商品ID列表(在定制前的状态) */
+    private List<Long> baseSkuIds;
+    /** 用户新增的商品ID列表 */
+    private List<Long> addedSkuIds;
+    /** 用户移除的商品ID列表 */
+    private List<Long> removedSkuIds;
+    /** 最终确定的商品ID列表(基础+新增-移除) */
+    private List<Long> finalSkuIds;
+    /** 可供添加的产品分类及对应的SKU选项 */
+    private Map<String, List<Long>> availableAdditions; // categoryId -> [productId, ...]
+    /** 当前套餐覆盖的维度 */
+    private List<String> currentDimensions;
+    /** 定制是否已修改过 (与原始套餐比较) */
+    private Boolean isModified;
+    /** 定制操作日志(简化版) */
+    private List<CustomizationLogEntry> logEntries;
+
+    @Data
+    public static class CustomizationLogEntry {
+        private String action; // "add" 或 "remove"
+        private Long skuId;
+        private String skuName;
+        private Date timestamp;
+    }
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProcurementTaskDTO.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class ProcurementTaskDTO {
+    /** 任务唯一标识 */
+    private String taskId;
+    /** 所属用户ID */
+    private Long userId;
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+    /** 选择的套餐等级: high|medium|low */
+    private String comboTier;
+    /** 套餐原始等级 (用于自定义追踪) */
+    private String originalComboTier;
+    /** 最终选中的商品ID列表(对应 products.id) */
+    private List<Long> selectedProductSkuIds;
+    /** 用户自新增的商品ID列表 */
+    private List<Long> customAdditionSkuIds;
+    /** 用户移除的商品ID列表 */
+    private List<Long> customRemovalSkuIds;
+    /** 任务状态 */
+    private String status; // SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED
+    /** 创建时间 */
+    private Date createTime;
+    /** 完成时间 */
+    private Date completeTime;
+    /** 是否已发送消息提醒 */
+    private Boolean messageReminderSent;
+    /** 关联的执行任务ID (status=EXECUTING时设置) */
+    private String executionTaskId;
+}

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProcurementTask.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 采购任务实体
+ * 管理商品组合推荐、用户定制、采购任务生命周期
+ */
+@Data
+@TableName("procurement_tasks")
+public class ProcurementTask implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 任务唯一标识 */
+    private String taskId;
+
+    /** 所属用户ID */
+    private Long userId;
+
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+
+    /** 选择的套餐等级: high|medium|low */
+    private String comboTier;
+
+    /** 原始套餐等级 (用于自定义追踪) */
+    private String originalComboTier;
+
+    /** 最终选中的产品SKU列表 (JSON数组) */
+    private String selectedProductSkuIds;
+
+    /** 用户自新增的产品SKU列表 (JSON数组) */
+    private String customAdditionSkuIds;
+
+    /** 用户移除的产品SKU列表 (JSON数组) */
+    private String customRemovalSkuIds;
+
+    /** 任务状态 */
+    private String status; // SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 完成时间 */
+    private Date completeTime;
+
+    /** 是否已发送消息提醒 */
+    private Integer messageReminderSent;
+
+    /** 关联的执行任务ID */
+    private String executionTaskId;
+
+    /** 更新时间 */
+    private Date updateTime;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProcurementTaskMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProcurementTask;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProcurementTaskMapper extends BaseMapper<ProcurementTask> {
+}

+ 301 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProcurementTaskService.java

@@ -0,0 +1,301 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.ProcurementTaskDTO;
+import com.etotem.cfc.entity.ProcurementTask;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProcurementTaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 采购任务服务
+ * 管理商品组合推荐、用户定制、采购任务全生命周期
+ */
+@Slf4j
+@Service
+public class ProcurementTaskService {
+
+    @Resource
+    private ProcurementTaskMapper procurementTaskMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private ProductSkuService productSkuService;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    /**
+     * 创建采购任务
+     */
+    @Transactional
+    public ProcurementTaskDTO createTask(Long userId, Long familyMemberId, String selectedTier,
+                                         List<Long> baseSkuIds, List<Long> addedSkuIds,
+                                         List<Long> removedSkuIds) {
+        List<Long> resolvedBase = resolveSkuIds(baseSkuIds);
+        List<Long> resolvedAdded = resolveSkuIds(addedSkuIds);
+        List<Long> resolvedRemoved = resolveSkuIds(removedSkuIds);
+        List<Long> finalSkuIds = mergeSkus(resolvedBase, resolvedAdded, resolvedRemoved);
+
+        ProcurementTaskDTO task = new ProcurementTaskDTO();
+        task.setTaskId(UUID.randomUUID().toString());
+        task.setUserId(userId);
+        task.setFamilyMemberId(familyMemberId);
+        task.setComboTier(selectedTier);
+        task.setOriginalComboTier(selectedTier);
+        task.setSelectedProductSkuIds(finalSkuIds);
+        task.setCustomAdditionSkuIds(resolvedAdded);
+        task.setCustomRemovalSkuIds(resolvedRemoved);
+        task.setStatus("SELECTED");
+        task.setCreateTime(new Date());
+        task.setMessageReminderSent(false);
+
+        saveTask(task);
+        log.info("创建采购任务: taskId={}, userId={}, tier={}, skus={}",
+                task.getTaskId(), userId, selectedTier, finalSkuIds);
+        return task;
+    }
+
+    /**
+     * 启动采购流程 - SELECTED → PROCURING → PROCURED → EXECUTING
+     */
+    @Transactional
+    public ProcurementTaskDTO startProcurement(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"SELECTED".equals(task.getStatus())) {
+            throw new RuntimeException("仅 SELECTED 状态的任务可进行采购流程");
+        }
+
+        task.setStatus("PROCURING");
+        updateTask(task);
+
+        // 模拟采购执行(实际应调用供应商API/库存服务)
+        boolean success = executeProcurement(task);
+
+        if (success) {
+            task.setStatus("PROCURED");
+            task.setExecutionTaskId(UUID.randomUUID().toString());
+            task.setStatus("EXECUTING");
+            log.info("采购成功,进入执行阶段: taskId={}, executionTaskId={}", taskId, task.getExecutionTaskId());
+        } else {
+            task.setStatus("CANCELLED");
+            log.warn("采购失败: taskId={}", taskId);
+        }
+
+        updateTask(task);
+        return task;
+    }
+
+    /**
+     * 完成任务执行 - EXECUTING → COMPLETED,发送消息提醒
+     */
+    @Transactional
+    public ProcurementTaskDTO completeExecution(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"EXECUTING".equals(task.getStatus())) {
+            throw new RuntimeException("仅 EXECUTING 状态的任务可标记为完成");
+        }
+
+        task.setStatus("COMPLETED");
+        task.setCompleteTime(new Date());
+
+        if (task.getMessageReminderSent() == null || !task.getMessageReminderSent()) {
+            sendCompletionReminder(task);
+            task.setMessageReminderSent(true);
+        }
+
+        updateTask(task);
+        log.info("执行任务完成: taskId={}, userId={}", taskId, task.getUserId());
+        return task;
+    }
+
+    /**
+     * 取消采购任务
+     */
+    @Transactional
+    public ProcurementTaskDTO cancelTask(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"SELECTED".equals(task.getStatus()) && !"PROCURING".equals(task.getStatus())) {
+            throw new RuntimeException("仅 SELECTED 或 PROCURING 状态的任务可取消");
+        }
+
+        task.setStatus("CANCELLED");
+        task.setCompleteTime(new Date());
+        updateTask(task);
+        log.info("取消采购任务: taskId={}, userId={}", taskId, task.getUserId());
+        return task;
+    }
+
+    /**
+     * 获取任务详情
+     */
+    public ProcurementTaskDTO getTaskById(String taskId) {
+        return convertToDTO(procurementTaskMapper.selectOne(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getTaskId, taskId)));
+    }
+
+    /**
+     * 获取用户的采购任务列表
+     */
+    public List<ProcurementTaskDTO> getUserTasks(Long userId) {
+        List<ProcurementTask> tasks = procurementTaskMapper.selectList(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getUserId, userId)
+                        .orderByDesc(ProcurementTask::getCreateTime));
+        return tasks.stream().map(this::convertToDTO).collect(Collectors.toList());
+    }
+
+    /** ---------------------------------------------------------
+     *  内部辅助方法
+     *  --------------------------------------------------------- */
+
+    private List<Long> resolveSkuIds(List<Long> skuIds) {
+        if (skuIds == null || skuIds.isEmpty()) return new ArrayList<>();
+        // 过滤无效 ID(<=0)
+        return skuIds.stream()
+                .filter(id -> id != null && id > 0)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private List<Long> mergeSkus(List<Long> base, List<Long> added, List<Long> removed) {
+        Set<Long> result = new LinkedHashSet<>(base);
+        result.addAll(added);
+        result.removeAll(removed);
+        return new ArrayList<>(result);
+    }
+
+    private boolean executeProcurement(ProcurementTaskDTO task) {
+        // 实际应:检查库存、调用供应商API、扣减库存、生成订单等
+        // 简化实现:假设采购成功
+        log.info("执行采购: taskId={}, skus={}", task.getTaskId(), task.getSelectedProductSkuIds());
+        return true;
+    }
+
+    private void sendCompletionReminder(ProcurementTaskDTO task) {
+        try {
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("comboTier", formatTierLabel(task.getComboTier()));
+            vars.put("productCount", task.getSelectedProductSkuIds() != null ? task.getSelectedProductSkuIds().size() : 0);
+            vars.put("createTime", task.getCreateTime() != null ?
+                    new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(task.getCreateTime()) : "");
+            notificationDispatchService.sendByTemplate(
+                    "procurement_completed",
+                    task.getUserId(),
+                    vars,
+                    "procurement_task",
+                    null,
+                    "采购任务完成",
+                    "您的采购任务已完成,产品即将送达。请及时查收!"
+            );
+            log.info("发送采购完成提醒: taskId={}, userId={}", task.getTaskId(), task.getUserId());
+        } catch (Exception e) {
+            log.warn("发送采购完成提醒失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
+        }
+    }
+
+    private String formatTierLabel(String tier) {
+        if (tier == null) return "未知";
+        switch (tier) {
+            case "high": return "尊享";
+            case "medium": return "平衡";
+            case "low": return "入门";
+            default: return tier;
+        }
+    }
+
+    private ProcurementTaskDTO convertToDTO(ProcurementTask task) {
+        if (task == null) return null;
+        ProcurementTaskDTO dto = new ProcurementTaskDTO();
+        dto.setTaskId(task.getTaskId());
+        dto.setUserId(task.getUserId());
+        dto.setFamilyMemberId(task.getFamilyMemberId());
+        dto.setComboTier(task.getComboTier());
+        dto.setOriginalComboTier(task.getOriginalComboTier());
+        dto.setSelectedProductSkuIds(fromJson(task.getSelectedProductSkuIds()));
+        dto.setCustomAdditionSkuIds(fromJson(task.getCustomAdditionSkuIds()));
+        dto.setCustomRemovalSkuIds(fromJson(task.getCustomRemovalSkuIds()));
+        dto.setStatus(task.getStatus());
+        dto.setCreateTime(task.getCreateTime());
+        dto.setCompleteTime(task.getCompleteTime());
+        dto.setMessageReminderSent(task.getMessageReminderSent() != null && task.getMessageReminderSent() == 1);
+        dto.setExecutionTaskId(task.getExecutionTaskId());
+        return dto;
+    }
+
+    private void saveTask(ProcurementTaskDTO dto) {
+        ProcurementTask task = new ProcurementTask();
+        task.setTaskId(dto.getTaskId());
+        task.setUserId(dto.getUserId());
+        task.setFamilyMemberId(dto.getFamilyMemberId());
+        task.setComboTier(dto.getComboTier());
+        task.setOriginalComboTier(dto.getOriginalComboTier());
+        task.setSelectedProductSkuIds(toJson(dto.getSelectedProductSkuIds()));
+        task.setCustomAdditionSkuIds(toJson(dto.getCustomAdditionSkuIds()));
+        task.setCustomRemovalSkuIds(toJson(dto.getCustomRemovalSkuIds()));
+        task.setStatus(dto.getStatus());
+        task.setCreateTime(dto.getCreateTime());
+        task.setCompleteTime(dto.getCompleteTime());
+        task.setMessageReminderSent(dto.getMessageReminderSent() != null && dto.getMessageReminderSent() ? 1 : 0);
+        task.setExecutionTaskId(dto.getExecutionTaskId());
+        task.setUpdateTime(new Date());
+        procurementTaskMapper.insert(task);
+    }
+
+    private void updateTask(ProcurementTaskDTO dto) {
+        ProcurementTask task = procurementTaskMapper.selectOne(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getTaskId, dto.getTaskId()));
+        if (task == null) return;
+        task.setComboTier(dto.getComboTier());
+        task.setOriginalComboTier(dto.getOriginalComboTier());
+        task.setSelectedProductSkuIds(toJson(dto.getSelectedProductSkuIds()));
+        task.setCustomAdditionSkuIds(toJson(dto.getCustomAdditionSkuIds()));
+        task.setCustomRemovalSkuIds(toJson(dto.getCustomRemovalSkuIds()));
+        task.setStatus(dto.getStatus());
+        task.setCompleteTime(dto.getCompleteTime());
+        task.setMessageReminderSent(dto.getMessageReminderSent() != null && dto.getMessageReminderSent() ? 1 : 0);
+        task.setExecutionTaskId(dto.getExecutionTaskId());
+        task.setUpdateTime(new Date());
+        procurementTaskMapper.updateById(task);
+    }
+
+    private String toJson(List<Long> list) {
+        if (list == null) return null;
+        return com.alibaba.fastjson.JSON.toJSONString(list);
+    }
+
+    private List<Long> fromJson(String json) {
+        if (json == null || json.isEmpty()) return new ArrayList<>();
+        try {
+            return com.alibaba.fastjson.JSON.parseArray(json, Long.class);
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+}

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -28,6 +28,7 @@ import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -71,6 +72,65 @@ public class ProductService {
         return productMapper.selectById(id);
     }
 
+    /**
+     * 按维度查询上架商品(用于商品组合方案生成)
+     * 支持价格区间筛选,返回前 limit 个上架商品
+     *
+     * @param dimensionCode 维度码 (body/mind/wisdom/action/wealth)
+     * @param minPrice      最低价(分,可为 null)
+     * @param maxPrice      最高价(分,可为 null)
+     * @param limit         最大返回数量
+     * @return 上架且维度匹配的商品列表
+     */
+    public List<Product> listByDimension(String dimensionCode, Integer minPrice, Integer maxPrice, int limit) {
+        if (dimensionCode == null || dimensionCode.isEmpty() || !DimensionCodes.isValid(dimensionCode)) {
+            return new ArrayList<>();
+        }
+        String dim = dimensionCode.trim();
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                .eq(Product::getStatus, "on_shelf")
+                .and(w -> w
+                        .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                        .or().eq(Product::getDomain, dim));
+        if (minPrice != null) {
+            wrapper.ge(Product::getPrice, minPrice);
+        }
+        if (maxPrice != null) {
+            wrapper.le(Product::getPrice, maxPrice);
+        }
+        wrapper.orderByAsc(Product::getSortOrder).orderByAsc(Product::getId).last("LIMIT " + Math.max(1, limit));
+        return productMapper.selectList(wrapper);
+    }
+
+    /**
+     * 按维度批量查询上架商品(五维方案生成用)
+     * 对每个维度码调用 listByDimension 并返回 维度码→商品列表 的映射
+     *
+     * @param dimensionCodes 维度码列表
+     * @param limitPerDim    每维度最大返回数量
+     * @return 维度码→上架商品列表
+     */
+    public Map<String, List<Product>> listByDimensions(List<String> dimensionCodes, int limitPerDim) {
+        Map<String, List<Product>> result = new LinkedHashMap<>();
+        if (dimensionCodes == null) return result;
+        for (String dim : dimensionCodes) {
+            if (dim != null && !dim.isEmpty()) {
+                result.put(dim, listByDimension(dim, null, null, limitPerDim));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 按商品名称精确查找(用于占位 SKU 名解析)
+     */
+    public Product findByName(String name) {
+        if (name == null || name.trim().isEmpty()) return null;
+        return productMapper.selectOne(new LambdaQueryWrapper<Product>()
+                .eq(Product::getName, name.trim())
+                .last("LIMIT 1"));
+    }
+
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId, String role) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()

+ 24 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6274,3 +6274,27 @@ CREATE TABLE IF NOT EXISTS indicator_correlations (
     INDEX idx_def_a (definition_id_a),
     INDEX idx_def_b (definition_id_b)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鎸囨爣鍏宠仈鍏崇郴(浜哄伐瀹氫箟+鏁版嵁鏍″噯)';
+
+-- 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+CREATE TABLE IF NOT EXISTS procurement_tasks (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
+    user_id BIGINT NOT NULL COMMENT '所属用户ID',
+    family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID',
+    combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low',
+    original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)',
+    selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)',
+    custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)',
+    custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)',
+    status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED',
+    create_time DATETIME NOT NULL COMMENT '创建时间',
+    complete_time DATETIME DEFAULT NULL COMMENT '完成时间',
+    message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒',
+    execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID',
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_task_id (task_id),
+    INDEX idx_user_id (user_id),
+    INDEX idx_family_member_id (family_member_id),
+    INDEX idx_status (status),
+    INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)';

+ 59 - 2
cfc-desktop-pet/src-tauri/src/api/mod.rs

@@ -23,6 +23,31 @@ impl std::fmt::Display for ApiError {
     }
 }
 
+/// 规范化 base_url:非本机主机的 `http://` 一律升级为 `https://`。
+///
+/// 生产 `http://cfc.cf-club.com` 会被 nginx 以 301 重定向到 https,而 reqwest 在
+/// 301/302/303 上会把 POST 降级为 GET —— 后端对 GET 一律返回 405
+/// ("请求方法不支持,本平台接口统一使用POST")。直接升级协议头即可让 POST 原样直达,
+/// 从根上消除该 405;本机 / 内网地址保留 http,便于本地联调。
+pub fn normalize_base_url(raw: &str) -> String {
+    let t = raw.trim().trim_end_matches('/');
+    if let Some(rest) = t.strip_prefix("http://") {
+        let host_port = rest.split('/').next().unwrap_or("");
+        let host = host_port.split(':').next().unwrap_or("");
+        let is_local = host == "localhost"
+            || host == "127.0.0.1"
+            || host == "::1"
+            || host == "[::1]"
+            || host.starts_with("192.168.")
+            || host.starts_with("10.")
+            || host.starts_with("172.");
+        if !is_local {
+            return format!("https://{rest}");
+        }
+    }
+    t.to_string()
+}
+
 pub struct ApiClient {
     http: reqwest::Client,
     base_url: RwLock<String>,
@@ -33,7 +58,7 @@ impl ApiClient {
     pub fn new(base_url: String, token: String) -> Self {
         Self {
             http: reqwest::Client::new(),
-            base_url: RwLock::new(base_url.trim_end_matches('/').to_string()),
+            base_url: RwLock::new(normalize_base_url(&base_url)),
             token: RwLock::new(token),
         }
     }
@@ -43,7 +68,7 @@ impl ApiClient {
     }
 
     pub fn set_base_url(&self, base_url: String) {
-        *self.base_url.write().unwrap() = base_url.trim_end_matches('/').to_string();
+        *self.base_url.write().unwrap() = normalize_base_url(&base_url);
     }
 
     pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
@@ -185,4 +210,36 @@ mod tests {
         let client = ApiClient::new("http://x".into(), "t".into());
         assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
     }
+
+    #[test]
+    fn normalize_upgrades_public_http_to_https() {
+        assert_eq!(
+            normalize_base_url("http://cfc.cf-club.com"),
+            "https://cfc.cf-club.com"
+        );
+        assert_eq!(
+            normalize_base_url("http://cfc.cf-club.com/"),
+            "https://cfc.cf-club.com"
+        );
+        assert_eq!(
+            normalize_base_url("  http://cfc.cf-club.com  "),
+            "https://cfc.cf-club.com"
+        );
+        // 已是 https 保持不变
+        assert_eq!(
+            normalize_base_url("https://cfc.cf-club.com"),
+            "https://cfc.cf-club.com"
+        );
+    }
+
+    #[test]
+    fn normalize_keeps_local_and_private_http() {
+        assert_eq!(normalize_base_url("http://localhost:9082"), "http://localhost:9082");
+        assert_eq!(normalize_base_url("http://127.0.0.1:9082"), "http://127.0.0.1:9082");
+        assert_eq!(
+            normalize_base_url("http://192.168.16.251:9082"),
+            "http://192.168.16.251:9082"
+        );
+        assert_eq!(normalize_base_url("http://10.0.0.5"), "http://10.0.0.5");
+    }
 }

+ 16 - 0
cfc-desktop-pet/src-tauri/src/api/models.rs

@@ -118,6 +118,10 @@ pub struct WechatQrResp {
     pub state: String,
     #[serde(default)]
     pub qr_image: Option<String>,
+    /// 旧版后端(微信开放平台 qrconnect 网页扫码)返回的字段;新版小程序码契约下为 None。
+    /// 保留以便识别「服务端未升级」并给出明确提示,而不是静默展示空码。
+    #[serde(default)]
+    pub url: Option<String>,
 }
 
 /// POST /api/auth/wechat-web/poll → {status, token?, userId?, role?, nickname?, message?}
@@ -230,6 +234,18 @@ mod tests {
         let q = r.data.unwrap();
         assert_eq!(q.state, "abc123");
         assert_eq!(q.qr_image.as_deref(), Some(""));
+        assert!(q.url.is_none());
+    }
+
+    #[test]
+    fn parses_legacy_wechat_qr_response_with_url() {
+        // 旧版后端(qrconnect)只返回 {state,url},无 qrImage
+        let json = r#"{"code":200,"message":"success","data":{"state":"s1","url":"https://open.weixin.qq.com/connect/qrconnect?appid=&state=s1"}}"#;
+        let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
+        let q = r.data.unwrap();
+        assert_eq!(q.state, "s1");
+        assert!(q.qr_image.is_none());
+        assert!(q.url.as_deref().unwrap_or("").contains("qrconnect"));
     }
 
     #[test]

+ 3 - 0
cfc-desktop-pet/src-tauri/src/app_state.rs

@@ -1,4 +1,5 @@
 use crate::api::ApiClient;
+use std::sync::atomic::AtomicBool;
 use std::sync::{Arc, Mutex};
 
 pub struct AppState {
@@ -6,5 +7,7 @@ pub struct AppState {
     pub poll_handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
     /// 当前提醒中、可一键完成的待办任务 id(点击宠物即完成)
     pub pending_task: Mutex<Option<i64>>,
+    /// 是否处于「站手跟随光标」模式(前端胸部悬停满 5s 后置真;抖动甩飞/退出后置假)
+    pub follow_cursor: AtomicBool,
     pub api: Arc<ApiClient>,
 }

+ 30 - 1
cfc-desktop-pet/src-tauri/src/commands.rs

@@ -3,6 +3,7 @@ use crate::poller;
 use crate::settings::SettingsStore;
 use crate::state::{PetState, PetStatePayload};
 use crate::token::TokenStore;
+use std::sync::atomic::Ordering;
 use tauri::{AppHandle, Emitter, Manager, State};
 
 #[tauri::command]
@@ -39,6 +40,20 @@ pub async fn drag_end() -> Result<(), String> {
     Ok(())
 }
 
+/// 站手跟随:前端「胸部悬停满 5s」后调用。locomotion 循环会接管窗口移动并回发 `pet://perch`。
+#[tauri::command]
+pub fn start_perch(state: State<AppState>) -> Result<(), String> {
+    state.follow_cursor.store(true, Ordering::Relaxed);
+    Ok(())
+}
+
+/// 退出站手跟随(前端主动中止;抖动甩飞是循环内自动退出)。
+#[tauri::command]
+pub fn stop_perch(state: State<AppState>) -> Result<(), String> {
+    state.follow_cursor.store(false, Ordering::Relaxed);
+    Ok(())
+}
+
 #[tauri::command]
 pub fn open_settings(app: AppHandle) -> Result<(), String> {
     if let Some(win) = app.get_webview_window("settings") {
@@ -158,10 +173,24 @@ pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, Str
             .map_err(|e| format!("获取二维码失败: {e}"))?
     };
 
+    // 兼容旧版后端:只返回 {state,url}(微信开放平台 qrconnect 网页扫码)而无小程序码。
+    // 此时新版登录流程无法继续,明确报错,避免静默展示空码让用户困惑。
+    let qr_image = qr.qr_image.clone().unwrap_or_default();
+    if qr_image.is_empty() && qr.url.as_deref().map(|u| !u.is_empty()).unwrap_or(false) {
+        let _ = app.emit(
+            "pet://wechat-login",
+            serde_json::json!({
+                "status": "error",
+                "message": "服务端仍是旧版登录接口(微信开放平台 qrconnect),请部署最新后端(小程序码 /scan)后重试"
+            }),
+        );
+        return Err("服务端登录接口版本过旧:缺少小程序码,需部署最新后端".into());
+    }
+
     // 将小程序码图片推给设置窗口展示(用户用微信扫码后在小程序内确认登录)
     let _ = app.emit(
         "pet://wechat-qr",
-        serde_json::json!({ "state": qr.state.clone(), "qrImage": qr.qr_image.clone() }),
+        serde_json::json!({ "state": qr.state.clone(), "qrImage": qr_image }),
     );
 
     let api = app.state::<AppState>().api.clone();

+ 414 - 0
cfc-desktop-pet/src-tauri/src/locomotion.rs

@@ -0,0 +1,414 @@
+//! 桌面宠物「精细化动作」运动控制:轮询全局光标 + 移动窗口,实现走 / 跳着走 / 飞 / 站手跟随。
+//!
+//! 设计:宠物窗口仅 200×200,跨屏走/飞必须**移动窗口本身**;窗口位置只能由 Rust 修改,
+//! 故本模块是「移动者」,前端 `pet-pose.js` 只负责渲染局部姿态(走路摆动/跳跃/扑腾)。
+//!
+//! 免全局鼠标钩子:用 `WebviewWindow::cursor_position()` 轮询全局光标(跨平台、无 macOS 辅助功能权限)。
+
+use crate::app_state::AppState;
+use serde::Serialize;
+use std::sync::atomic::Ordering;
+use std::time::{Duration, Instant};
+use tauri::{AppHandle, Emitter, Manager, PhysicalPosition};
+
+/// 循环间隔(ms)
+const TICK_MS: u64 = 60;
+/// 宠物窗口物理尺寸(与 tauri.conf.json 保持一致)
+const WIN_W: i32 = 200;
+const WIN_H: i32 = 200;
+/// 宠物双脚在窗口内的锚点(窗口左上角为原点)。站手时双脚落在光标处。
+const FEET_ANCHOR_X: i32 = 100;
+const FEET_ANCHOR_Y: i32 = 200;
+
+// ── 宠物可视包围盒(窗口内),用于「鼠标悬停在宠物身上 → 暂停活动」判定 ──
+// 与 index.html 的 #pet(120×140,底部居中)布局保持一致:left=(200-120)/2=40,top=200-140=60
+const PET_BOX_OFFSET_X: i32 = 40;
+const PET_BOX_OFFSET_Y: i32 = 60;
+const PET_BOX_W: i32 = 120;
+const PET_BOX_H: i32 = 140;
+
+// ── 抖动检测参数 ──
+const SHAKE_WINDOW_MS: u128 = 600;
+const SHAKE_MIN_REVERSALS: usize = 4;
+const SHAKE_MIN_TRAVEL: f64 = 80.0;
+/// 甩飞后抖动冷却(tick 数,60ms/tick → 约 1.8s),避免落地即再触发
+const SHAKE_COOLDOWN_TICKS: u32 = 30;
+const MAX_SAMPLES: usize = 40;
+
+// ── 步态参数(每 tick 位移,60ms/tick → 约 27px/s 走 / 43px/s 跳)──
+const WALK_STEP: f64 = 1.6;
+const HOP_STEP: f64 = 2.6;
+const FLY_TICKS: u32 = 10; // ≈600ms
+const IDLE_MIN_TICKS: u32 = 45; // ≈2.7s 起才会有自主动作
+const IDLE_JITTER_TICKS: u64 = 70;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Mode {
+    Idle,
+    Walk,
+    Hop,
+    Fly,
+    Perch,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct Sample {
+    pub x: f64,
+    pub y: f64,
+    pub t: Instant,
+}
+
+/// 抖动检测:在时间窗口内统计水平方向反转次数与总位移。
+/// 反转 ≥4 次且总位移 ≥80px → 视为「抖动鼠标」。
+pub fn detect_shake(samples: &[Sample], now: Instant) -> bool {
+    let recent: Vec<&Sample> = samples
+        .iter()
+        .filter(|s| now.duration_since(s.t).as_millis() <= SHAKE_WINDOW_MS)
+        .collect();
+    if recent.len() < 4 {
+        return false;
+    }
+    let mut reversals = 0usize;
+    let mut travel = 0.0f64;
+    let mut prev_dir = 0i32;
+    for w in recent.windows(2) {
+        let dx = w[1].x - w[0].x;
+        if dx.abs() < 1.0 {
+            continue;
+        }
+        travel += dx.abs();
+        let dir = if dx > 0.0 { 1 } else { -1 };
+        if prev_dir != 0 && dir != prev_dir {
+            reversals += 1;
+        }
+        prev_dir = dir;
+    }
+    reversals >= SHAKE_MIN_REVERSALS && travel >= SHAKE_MIN_TRAVEL
+}
+
+/// 把窗口左上角夹进工作区,保证整窗可见。
+pub fn clamp_window_pos(x: i32, y: i32, wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32) -> (i32, i32) {
+    let max_x = wa_x + (wa_w - WIN_W).max(0);
+    let max_y = wa_y + (wa_h - WIN_H).max(0);
+    (x.clamp(wa_x, max_x), y.clamp(wa_y, max_y))
+}
+
+/// 光标是否落在窗口内的宠物包围盒上(用于「悬停在宠物身上 → 暂停活动」)。
+pub fn cursor_over_pet(cursor: (f64, f64), win_pos: (i32, i32)) -> bool {
+    let left = (win_pos.0 + PET_BOX_OFFSET_X) as f64;
+    let top = (win_pos.1 + PET_BOX_OFFSET_Y) as f64;
+    let right = left + PET_BOX_W as f64;
+    let bottom = top + PET_BOX_H as f64;
+    cursor.0 >= left && cursor.0 <= right && cursor.1 >= top && cursor.1 <= bottom
+}
+
+/// 工作区内的确定性随机落点(LCG 单步,便于单测)。
+pub fn pick_random_target(wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32, seed: u64) -> (i32, i32) {
+    let s = seed
+        .wrapping_mul(6364136223846793005)
+        .wrapping_add(1442695040888963407);
+    let rx = ((s >> 33) as u32) as f64 / (u32::MAX as f64);
+    let ry = ((s >> 11) as u32) as f64 / (u32::MAX as f64);
+    let max_x = wa_x + (wa_w - WIN_W).max(0);
+    let max_y = wa_y + (wa_h - WIN_H).max(0);
+    let x = wa_x + (rx * (max_x - wa_x).max(0) as f64).round() as i32;
+    let y = wa_y + (ry * (max_y - wa_y).max(0) as f64).round() as i32;
+    clamp_window_pos(x, y, wa_x, wa_y, wa_w, wa_h)
+}
+
+fn next_rand(seed: &mut u64) -> u64 {
+    *seed = seed
+        .wrapping_mul(6364136223846793005)
+        .wrapping_add(1442695040888963407);
+    *seed
+}
+
+fn work_area(win: &tauri::WebviewWindow) -> (i32, i32, i32, i32) {
+    if let Ok(Some(m)) = win.current_monitor() {
+        let wa = m.work_area();
+        (wa.position.x, wa.position.y, wa.size.width as i32, wa.size.height as i32)
+    } else {
+        (0, 0, 1920, 1080)
+    }
+}
+
+fn set_pos(win: &tauri::WebviewWindow, x: i32, y: i32) {
+    let _ = win.set_position(PhysicalPosition::new(x, y));
+}
+
+fn emit_gait(
+    app: &AppHandle,
+    last: &mut Option<(&'static str, &'static str)>,
+    kind: &'static str,
+    direction: &'static str,
+) {
+    if *last == Some((kind, direction)) {
+        return;
+    }
+    *last = Some((kind, direction));
+    let _ = app.emit(
+        "pet://gait",
+        GaitPayload { kind, direction },
+    );
+}
+
+#[derive(Serialize, Clone)]
+struct GaitPayload {
+    kind: &'static str,
+    direction: &'static str,
+}
+
+/// 启动运动控制循环(在 `app.manage(AppState)` 之后调用)。
+pub fn spawn(app: AppHandle) {
+    tauri::async_runtime::spawn(async move {
+        let mut last_pos: (i32, i32) = app
+            .get_webview_window("main")
+            .and_then(|w| w.outer_position().ok())
+            .map(|p| (p.x, p.y))
+            .unwrap_or((0, 0));
+        let mut mode = Mode::Idle;
+        let mut target: (i32, i32) = last_pos;
+        let mut fly_from: (i32, i32) = last_pos;
+        let mut fly_left: u32 = 0;
+        let mut idle_ticks: u32 = 0;
+        let mut shake_cooldown: u32 = 0;
+        let mut samples: Vec<Sample> = Vec::new();
+        let mut last_gait: Option<(&'static str, &'static str)> = Some(("idle", "none"));
+        let mut seed: u64 = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_nanos() as u64)
+            .unwrap_or(0x1234_5678_9abc_def0);
+
+        loop {
+            tokio::time::sleep(Duration::from_millis(TICK_MS)).await;
+
+            let Some(win) = app.get_webview_window("main") else {
+                continue;
+            };
+            let now = Instant::now();
+            let (wa_x, wa_y, wa_w, wa_h) = work_area(&win);
+            let follow = app
+                .try_state::<AppState>()
+                .map(|s| s.follow_cursor.load(Ordering::Relaxed))
+                .unwrap_or(false);
+            if shake_cooldown > 0 {
+                shake_cooldown -= 1;
+            }
+
+            // 采样全局光标(用于抖动检测 / 站手跟随 / 悬停暂停)
+            let mut cursor: Option<(f64, f64)> = None;
+            if let Ok(cur) = win.cursor_position() {
+                cursor = Some((cur.x, cur.y));
+                samples.push(Sample { x: cur.x, y: cur.y, t: now });
+                if samples.len() > MAX_SAMPLES {
+                    samples.remove(0);
+                }
+            }
+
+            if follow {
+                if mode != Mode::Perch {
+                    mode = Mode::Perch;
+                    samples.clear(); // 丢弃进入前的光标历史,避免接近动作被误判为抖动
+                    emit_gait(&app, &mut last_gait, "perch", "none");
+                    let _ = app.emit("pet://perch", serde_json::json!({ "active": true }));
+                }
+                if shake_cooldown == 0 && detect_shake(&samples, now) {
+                    // 抖动 → 甩飞到工作区随机点
+                    if let Some(s) = app.try_state::<AppState>() {
+                        s.follow_cursor.store(false, Ordering::Relaxed);
+                    }
+                    let (tx, ty) = pick_random_target(wa_x, wa_y, wa_w, wa_h, next_rand(&mut seed));
+                    fly_from = last_pos;
+                    target = (tx, ty);
+                    fly_left = FLY_TICKS;
+                    mode = Mode::Fly;
+                    shake_cooldown = SHAKE_COOLDOWN_TICKS;
+                    samples.clear();
+                    let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
+                    emit_gait(&app, &mut last_gait, "fly", "none");
+                } else if let Some(s) = samples.last() {
+                    // 站手跟随:窗口 = 光标 - 双脚锚点
+                    let (cx, cy) = clamp_window_pos(
+                        s.x as i32 - FEET_ANCHOR_X,
+                        s.y as i32 - FEET_ANCHOR_Y,
+                        wa_x,
+                        wa_y,
+                        wa_w,
+                        wa_h,
+                    );
+                    set_pos(&win, cx, cy);
+                    last_pos = (cx, cy);
+                }
+                continue;
+            } else if mode == Mode::Perch {
+                mode = Mode::Idle;
+                idle_ticks = 0;
+                let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
+                emit_gait(&app, &mut last_gait, "idle", "none");
+            }
+
+            // 鼠标悬停在宠物身上时暂停自主动作(走/跳/飞 → 立即待机)
+            let hovered = cursor.map(|c| cursor_over_pet(c, last_pos)).unwrap_or(false);
+            if hovered && matches!(mode, Mode::Walk | Mode::Hop | Mode::Fly) {
+                mode = Mode::Idle;
+                idle_ticks = 0;
+                emit_gait(&app, &mut last_gait, "idle", "none");
+            }
+
+            match mode {
+                Mode::Fly => {
+                    if fly_left > 0 {
+                        fly_left -= 1;
+                        let t = 1.0 - (fly_left as f64 / FLY_TICKS as f64);
+                        let x = fly_from.0 as f64 + (target.0 - fly_from.0) as f64 * t;
+                        let y = fly_from.1 as f64 + (target.1 - fly_from.1) as f64 * t;
+                        let (cx, cy) = clamp_window_pos(x as i32, y as i32, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                    } else {
+                        mode = Mode::Idle;
+                        idle_ticks = 0;
+                        emit_gait(&app, &mut last_gait, "idle", "none");
+                    }
+                }
+                Mode::Walk | Mode::Hop => {
+                    let step = if mode == Mode::Walk { WALK_STEP } else { HOP_STEP };
+                    let dx = target.0 as f64 - last_pos.0 as f64;
+                    if dx.abs() <= step {
+                        let (cx, cy) = clamp_window_pos(target.0, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                        mode = Mode::Idle;
+                        idle_ticks = 0;
+                        emit_gait(&app, &mut last_gait, "idle", "none");
+                    } else {
+                        let nx = last_pos.0 as f64 + step * dx.signum();
+                        let (cx, cy) = clamp_window_pos(nx as i32, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                    }
+                }
+                _ => {
+                    if hovered {
+                        // 悬停期间保持静止:不累积空闲计时,不触发新的走/跳
+                        idle_ticks = 0;
+                    } else {
+                        idle_ticks += 1;
+                        let threshold =
+                            IDLE_MIN_TICKS + (next_rand(&mut seed) % IDLE_JITTER_TICKS) as u32;
+                        if idle_ticks >= threshold {
+                            idle_ticks = 0;
+                            let hop = next_rand(&mut seed) % 2 == 0;
+                            let dir: i32 = if next_rand(&mut seed) % 2 == 0 { 1 } else { -1 };
+                            let dist = 60 + (next_rand(&mut seed) % 120) as i32;
+                            let (tx, ty) = clamp_window_pos(
+                                last_pos.0 + dir * dist,
+                                last_pos.1,
+                                wa_x,
+                                wa_y,
+                                wa_w,
+                                wa_h,
+                            );
+                            target = (tx, ty);
+                            mode = if hop { Mode::Hop } else { Mode::Walk };
+                            emit_gait(
+                                &app,
+                                &mut last_gait,
+                                if hop { "hop" } else { "walk" },
+                                if dir > 0 { "right" } else { "left" },
+                            );
+                        }
+                    }
+                }
+            }
+        }
+    });
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample(x: f64, t: Instant) -> Sample {
+        Sample { x, y: 0.0, t }
+    }
+
+    #[test]
+    fn shake_detected_on_rapid_reversals() {
+        let now = Instant::now();
+        let base = now - Duration::from_millis(500);
+        let xs = [0.0, 40.0, 0.0, 40.0, 0.0, 40.0];
+        let samples: Vec<Sample> = xs
+            .iter()
+            .enumerate()
+            .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
+            .collect();
+        assert!(detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_on_monotonic_movement() {
+        let now = Instant::now();
+        let base = now - Duration::from_millis(500);
+        let xs = [0.0, 40.0, 80.0, 120.0, 160.0, 200.0];
+        let samples: Vec<Sample> = xs
+            .iter()
+            .enumerate()
+            .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
+            .collect();
+        assert!(!detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_when_too_few_samples() {
+        let now = Instant::now();
+        let samples = vec![sample(0.0, now), sample(5.0, now)];
+        assert!(!detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_when_reversals_outside_window() {
+        let now = Instant::now();
+        let old = now - Duration::from_millis(2000);
+        let old_samples: Vec<Sample> = (0..8)
+            .map(|i| sample(if i % 2 == 0 { 0.0 } else { 40.0 }, old))
+            .collect();
+        assert!(!detect_shake(&old_samples, now));
+    }
+
+    #[test]
+    fn clamp_keeps_window_inside_work_area() {
+        assert_eq!(clamp_window_pos(-50, -50, 0, 0, 1920, 1080), (0, 0));
+        assert_eq!(clamp_window_pos(5000, 5000, 0, 0, 1920, 1080), (1720, 880));
+        assert_eq!(clamp_window_pos(100, 100, 0, 0, 1920, 1080), (100, 100));
+    }
+
+    #[test]
+    fn clamp_handles_offset_work_area() {
+        // 副屏起点 (1920, 0)
+        assert_eq!(clamp_window_pos(1900, -10, 1920, 0, 1280, 1024), (1920, 0));
+        assert_eq!(clamp_window_pos(9999, 9999, 1920, 0, 1280, 1024), (3000, 824));
+    }
+
+    #[test]
+    fn random_target_stays_within_bounds() {
+        for seed in [0u64, 1, 42, 123_456, u64::MAX] {
+            let (x, y) = pick_random_target(0, 0, 1920, 1080, seed);
+            assert!((0..=1720).contains(&x), "x={x}");
+            assert!((0..=880).contains(&y), "y={y}");
+        }
+    }
+
+    #[test]
+    fn cursor_over_pet_detects_hit_and_miss() {
+        // 窗口左上角 (100,100):宠物盒 = x∈[140,260], y∈[160,300]
+        assert!(cursor_over_pet((200.0, 250.0), (100, 100)));
+        assert!(!cursor_over_pet((10.0, 250.0), (100, 100)));
+        assert!(!cursor_over_pet((200.0, 10.0), (100, 100)));
+        // 边界包含
+        assert!(cursor_over_pet((140.0, 160.0), (100, 100)));
+        assert!(cursor_over_pet((260.0, 300.0), (100, 100)));
+        // 窗口移动后包围盒随之平移
+        assert!(cursor_over_pet((500.0, 600.0), (400, 450)));
+    }
+}

+ 6 - 0
cfc-desktop-pet/src-tauri/src/main.rs

@@ -4,6 +4,7 @@ mod api;
 mod app_state;
 mod commands;
 mod config;
+mod locomotion;
 mod poller;
 mod settings;
 mod state;
@@ -36,8 +37,11 @@ fn main() {
                 passthrough: std::sync::Mutex::new(false),
                 poll_handle: std::sync::Mutex::new(Some(handle)),
                 pending_task: std::sync::Mutex::new(None),
+                follow_cursor: std::sync::atomic::AtomicBool::new(false),
                 api,
             });
+            // 精细化动作运动控制:轮询全局光标 + 移动窗口(走/跳/飞/站手跟随)
+            locomotion::spawn(app.handle().clone());
             tray::setup_tray(app)?;
             Ok(())
         })
@@ -45,6 +49,8 @@ fn main() {
             commands::on_interact,
             commands::drag_start,
             commands::drag_end,
+            commands::start_perch,
+            commands::stop_perch,
             commands::get_settings,
             commands::save_settings,
             commands::open_settings,

+ 53 - 0
cfc-desktop-pet/src/animations.css

@@ -22,6 +22,59 @@
 @keyframes puzzle_active { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(180deg) } }
 @keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } }
 
+/* ===== 精细化动作:姿态层(#pet-body)=====
+   由 locomotion.rs 的 pet://gait 事件驱动;与 #pet 上的情绪动画(.anim-*)分层叠加。
+   朝向:--face 由 .pose-face-left 切换(1=右,-1=左),关键帧统一 scaleX(var(--face,1))。 */
+
+/* 走:上下颠簸 + 左右重心摆 + 前后微倾,模拟迈步 */
+@keyframes walk_bob {
+  0%   { transform: scaleX(var(--face,1)) translateY(0)    rotate(0deg) }
+  25%  { transform: scaleX(var(--face,1)) translateY(-3px) rotate(-3.5deg) }
+  50%  { transform: scaleX(var(--face,1)) translateY(-1px) rotate(0deg) }
+  75%  { transform: scaleX(var(--face,1)) translateY(-3px) rotate(3.5deg) }
+  100% { transform: scaleX(var(--face,1)) translateY(0)    rotate(0deg) }
+}
+
+/* 跳着走:下蹲预备 → 起跳拉伸 → 滞空 → 落地挤压缓冲 */
+@keyframes hop_squash {
+  0%   { transform: scaleX(var(--face,1)) translateY(0)     scale(1, 1) }
+  18%  { transform: scaleX(var(--face,1)) translateY(2px)   scale(1.09, 0.9) }
+  45%  { transform: scaleX(var(--face,1)) translateY(-15px) scale(0.9, 1.15) }
+  62%  { transform: scaleX(var(--face,1)) translateY(-11px) scale(0.95, 1.05) }
+  82%  { transform: scaleX(var(--face,1)) translateY(0)     scale(1.07, 0.91) }
+  100% { transform: scaleX(var(--face,1)) translateY(0)     scale(1, 1) }
+}
+
+/* 飞:高频振翅(垂直挤压振荡)+ 抬升 + 侧倾 */
+@keyframes fly_flutter {
+  0%   { transform: scaleX(var(--face,1)) translateY(-2px)  rotate(-8deg) scale(1.02, 0.98) }
+  25%  { transform: scaleX(var(--face,1)) translateY(-11px) rotate(5deg)  scale(0.93, 1.09) }
+  50%  { transform: scaleX(var(--face,1)) translateY(-4px)  rotate(-6deg) scale(1.07, 0.93) }
+  75%  { transform: scaleX(var(--face,1)) translateY(-13px) rotate(7deg)  scale(0.91, 1.11) }
+  100% { transform: scaleX(var(--face,1)) translateY(-2px)  rotate(-8deg) scale(1.02, 0.98) }
+}
+
+/* 站手:随手指轻晃 + 微起伏 */
+@keyframes perch_lean {
+  0%   { transform: scaleX(var(--face,1)) rotate(-2.5deg) translateY(0) }
+  50%  { transform: scaleX(var(--face,1)) rotate(2.5deg)  translateY(-2px) }
+  100% { transform: scaleX(var(--face,1)) rotate(-2.5deg) translateY(0) }
+}
+
+/* 待机:极轻微呼吸起伏(无位移,避免与情绪动画冲突) */
+@keyframes idle_breathe {
+  0%   { transform: scaleX(var(--face,1)) scale(1, 1) }
+  50%  { transform: scaleX(var(--face,1)) scale(1.02, 0.985) }
+  100% { transform: scaleX(var(--face,1)) scale(1, 1) }
+}
+
+.pose-walk { animation: walk_bob 0.75s ease-in-out infinite; }
+.pose-hop { animation: hop_squash 0.62s ease-in-out infinite; }
+.pose-fly { animation: fly_flutter 0.3s ease-in-out infinite; }
+.pose-perch { animation: perch_lean 1.8s ease-in-out infinite; }
+.pose-idle { animation: idle_breathe 3.2s ease-in-out infinite; }
+.pose-face-left { --face: -1; }
+
 .anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; }
 .anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; }
 .anim-head_tilt { animation: head_tilt 1.6s ease-in-out infinite; }

+ 6 - 0
cfc-desktop-pet/src/bridge.js

@@ -60,6 +60,12 @@
     },
     onDragEnd: function () {
       try { invoke('drag_end'); } catch (e) { /* noop */ }
+    },
+    startPerch: function () {
+      try { invoke('start_perch'); } catch (e) { /* noop */ }
+    },
+    stopPerch: function () {
+      try { invoke('stop_perch'); } catch (e) { /* noop */ }
     }
   };
 })();

+ 18 - 3
cfc-desktop-pet/src/index.html

@@ -16,11 +16,22 @@
     background: transparent !important;
   }
   #pet {
-    width: 120px; height: 140px; pointer-events: auto;
-    cursor: grab; user-select: none;
+    position: relative; width: 120px; height: 140px; pointer-events: auto;
+    cursor: pointer; user-select: none;
     -webkit-app-region: no-drag;
   }
+  #pet-body {
+    position: relative; width: 100%; height: 100%;
+    transform-origin: 50% 100%;
+    cursor: pointer; --face: 1;
+  }
   #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
+  /* 胸部热区:悬停 5s → 站手跟随(纯 hover,无需按键) */
+  #chest {
+    position: absolute; left: 50%; top: 16%; width: 55%; height: 42%;
+    transform: translateX(-50%);
+    pointer-events: auto; cursor: pointer; background: transparent;
+  }
   #bubble {
     position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
     background: rgba(255,255,255,0.95); border: 2px solid #4A9BD7;
@@ -51,7 +62,10 @@
 <body>
 <div id="pet-stage">
   <div id="pet">
-    <img id="pet-img" src="" alt="pet" draggable="false" />
+    <div id="pet-body">
+      <img id="pet-img" src="" alt="pet" draggable="false" />
+    </div>
+    <div id="chest"></div>
     <div id="bubble"></div>
   </div>
 </div>
@@ -64,6 +78,7 @@
 </div>
 <script src="animation-gen.js"></script>
 <script src="pet-engine.js"></script>
+<script src="pet-pose.js"></script>
 <script src="bridge.js"></script>
 <script src="interactions.js"></script>
 </body>

+ 25 - 0
cfc-desktop-pet/src/interactions.js

@@ -2,12 +2,15 @@
   'use strict';
 
   const petEl = document.getElementById('pet');
+  const chest = document.getElementById('chest');
   const ctxMenu = document.getElementById('context-menu');
   const invoke = window.__TAURI__?.core?.invoke;
 
   let dragStartX = 0, dragStartY = 0;
   let pressTimer = null;
   let isDragging = false;
+  const CHEST_HOLD_MS = 5000;
+  let chestTimer = null;
 
   // ── 右键菜单 ──────────────────────────────
   petEl.addEventListener('contextmenu', function (e) {
@@ -37,6 +40,7 @@ ctxMenu.addEventListener('click', function (e) {
 
   // ── 点击 / 长按 / 拖拽 ───────────────────
   petEl.addEventListener('mousedown', function (e) {
+    if (window.__petPerched) return; // 站手跟随中:窗口由 Rust 驱动,禁用原生拖拽
     dragStartX = e.clientX;
     dragStartY = e.clientY;
     isDragging = false;
@@ -67,6 +71,27 @@ ctxMenu.addEventListener('click', function (e) {
     }
   });
 
+  // ── 胸部悬停 5s → 站手跟随 ────────────────
+  if (chest) {
+    chest.addEventListener('mouseenter', function () {
+      if (window.__petPerched) return;
+      clearChestTimer();
+      chestTimer = setTimeout(function () {
+        chestTimer = null;
+        if (window.__petPerched) return;
+        window.__petPerched = true; // 乐观置位;Rust 会回发 pet://perch 确认
+        try { window.cfcHost.startPerch(); } catch (_) { /* noop */ }
+        try { window.cfcPetAPI.showBubble('飞到你手上~', 2000); } catch (_) { /* noop */ }
+      }, CHEST_HOLD_MS);
+    });
+    // 悬停未满 5s 就移开 → 取消;站手后窗口跟随光标会使指针移出胸部,此处仅为清理(无害)
+    chest.addEventListener('mouseleave', clearChestTimer);
+  }
+
+  function clearChestTimer() {
+    if (chestTimer) { clearTimeout(chestTimer); chestTimer = null; }
+  }
+
   // 本地即时反馈:不依赖后端也能看到宠物对点击的响应。
   function localReact(kind) {
     try { if (window.cfcPetAPI && window.cfcPetAPI.react) window.cfcPetAPI.react(kind); } catch (_) { /* noop */ }

+ 43 - 0
cfc-desktop-pet/src/pet-pose.js

@@ -0,0 +1,43 @@
+// 姿态渲染层:监听 locomotion.rs 的 pet://gait / pet://perch,把动作映射到 #pet-body 上的 .pose-* 类。
+// 与 #pet 上的情绪动画(pet-engine.js 管理 .anim-*)分层叠加,互不覆盖。
+(function () {
+  'use strict';
+
+  var petBody = document.getElementById('pet-body');
+  if (!petBody) return;
+
+  var POSES = { idle: 'pose-idle', walk: 'pose-walk', hop: 'pose-hop', fly: 'pose-fly', perch: 'pose-perch' };
+  var currentPose = null;
+  var lastDir = 'right';
+
+  function apply(kind, direction) {
+    if (direction === 'left' || direction === 'right') lastDir = direction;
+    var cls = POSES[kind] || 'pose-idle'; // 未知 kind / idle → 呼吸待机
+    if (currentPose !== cls) {
+      if (currentPose) petBody.classList.remove(currentPose);
+      currentPose = cls;
+      void petBody.offsetWidth; // 强制回流,确保动画可重播
+      petBody.classList.add(cls);
+    }
+    // 朝向:left → 水平镜像(fly/perch 的 direction 为 none,沿用上次朝向)
+    petBody.classList.toggle('pose-face-left', lastDir === 'left');
+  }
+
+  // 初始即进入呼吸待机(Rust 侧初始 gait 不会 emit,故此处兜底)
+  apply('idle');
+
+  try {
+    var listen = window.__TAURI__.event.listen;
+    listen('pet://gait', function (e) {
+      var p = (e && e.payload) || {};
+      apply(p.kind, p.direction);
+    });
+    listen('pet://perch', function (e) {
+      window.__petPerched = !!(e.payload && e.payload.active);
+      // 站手瞬间 gait 事件已带 perch;此处兜底确保姿态出现
+      if (window.__petPerched) apply('perch');
+    });
+  } catch (err) {
+    console.log('[pet-pose] tauri events unavailable', err);
+  }
+})();

+ 2 - 7
cfc-desktop-pet/src/settings.html

@@ -23,8 +23,6 @@
 <body>
   <label for="base-url">后端地址</label>
   <input id="base-url" placeholder="https://cfc.cf-club.com" />
-  <label for="token">令牌(留空则不修改)</label>
-  <input id="token" type="password" placeholder="JWT Token" />
   <button id="save">保存</button>
 
   <hr />
@@ -49,7 +47,6 @@
       'use strict';
       var invoke = window.__TAURI__.core.invoke;
       var baseUrlEl = document.getElementById('base-url');
-      var tokenEl = document.getElementById('token');
       var statusEl = document.getElementById('status');
       var fileEl = document.getElementById('mascot-file');
       var previewEl = document.getElementById('mascot-preview');
@@ -64,13 +61,12 @@
           previewEl.src = s.customMascotUrl;
           previewEl.style.display = 'block';
         }
-        setStatus(s.hasToken ? '已保存令牌' : '未设置令牌');
+        setStatus('');
       }).catch(function () {});
 
       document.getElementById('save').addEventListener('click', function () {
         var baseUrl = baseUrlEl.value.trim();
-        var token = tokenEl.value.trim();
-        invoke('save_settings', { baseUrl: baseUrl, token: token ? token : null })
+        invoke('save_settings', { baseUrl: baseUrl })
           .then(function () {
             setStatus('已保存,轮询已重启');
             setTimeout(function () { setStatus(''); }, 2500);
@@ -160,7 +156,6 @@
           var p = (ev && ev.payload) || {};
           if (p.status === 'success') {
             setStatus('登录成功' + (p.nickname ? ':' + p.nickname : ''));
-            tokenEl.value = '';
             document.getElementById('wechat-qr').style.display = 'none';
           } else if (p.status === 'pending') {
             setStatus('等待扫码…');

+ 118 - 0
docs/superpowers/specs/2026-10-12-cockatiel-actions-design.md

@@ -0,0 +1,118 @@
+# 玄凤鹦鹉精细化动作 设计文档
+
+- 日期:2026-10-12
+- 组件:`cfc-desktop-pet`(Tauri v2 桌面宠物)
+- 平台范围:**先 Windows**(代码跨平台可编译,macOS 坐标/多屏细化留后续)
+
+## 1. 需求(原文)
+
+> 实现宠物的精细化动作,比如对于玄凤鹦鹉,要能够走路,也能够跳着走。鼠标放在鹦鹉上时变成手指的样式,放在胸部 5 秒钟,鹦鹉会迈到手指上,然后手指鼠标移到哪儿,鹦鹉就跟到哪儿。鼠标抖动一下鹦鹉会飞到屏幕的任意地方。
+
+拆解为 4 个行为:
+
+1. **自主走 / 跳着走**:空闲时宠物会在屏幕上走动,或跳着走。
+2. **悬停变手指**:鼠标移到鹦鹉身上时光标变为手指(pointer)。
+3. **胸部按住 5 秒 → 站到手指上并跟随**:悬停鹦鹉胸部 5 秒,鹦鹉迈到"手指"(光标)上,之后跟随鼠标移动。
+4. **抖动甩飞**:跟随(站手)状态下抖动鼠标,鹦鹉飞到屏幕任意位置后落地。
+
+## 2. 决策(已与用户确认)
+
+- **动作素材:程序化动作**。当前 `src/DefaultPets/{xibao,fubao}/` 只有 `pet-config.json`,**无任何图片**,宠物实际渲染 emoji 兜底形象。因此不依赖精灵帧,改用「整体位移 / 弹跳 / 挤压拉伸 / 旋转 / 扑腾」等程序化姿态 + **移动窗口**实现动作。
+- **范围:先 Windows**。
+
+## 3. 核心架构
+
+宠物所在窗口仅 200×200 且透明置顶,**装不下跨屏走动/飞行**——所以"走/跳/飞/跟随"必须**移动窗口本身**。窗口位置只能由 Rust 侧修改,故分层:
+
+| 层 | 职责 |
+|---|---|
+| **Rust(移动者)** | 轮询全局光标 `cursor_position()`;按显示器 `current_monitor().work_area()` 夹取边界;移动窗口 `set_position()`;抖动检测;驱动 locomotion 状态机;通过 `pet://gait` 事件把姿态种类下发给前端 |
+| **JS(渲染者)** | 只渲染**局部姿态**(走路摆动、跳跃挤压、飞行扑腾、站手平衡),以及窗口内的悬停/胸部悬停判定与气泡 |
+
+选用理由:避免 JS 每帧 `invoke` 跨进程调用造成卡顿(方案 B 被否),不引入物理/动画库(方案 C 被否,YAGNI)。
+
+### 关键 Tauri API(已在本机 crate 源码确认存在)
+
+- `WebviewWindow::cursor_position() -> Result<PhysicalPosition<f64>>`(全局光标,无需全局钩子 → 免 macOS 辅助功能权限)
+- `WebviewWindow::set_position(Position)` / `outer_position()` / `outer_size()`
+- `WebviewWindow::current_monitor() -> Result<Option<Monitor>>`
+- `Monitor::work_area() -> &PhysicalRect<i32,u32>`(`position` + `size`)
+
+### 分层动画合成
+
+`#pet`(情绪动画,由 `pet-engine.js` 管理 `.anim-*`)内新增 `#pet-body` 包装层承载**姿态动画**(`.pose-*`)。两层 transform 独立叠加,互不覆盖。
+
+```
+#pet  ← 情绪:.anim-wings_idle / .anim-clap …(pet-engine)
+  └── #pet-body  ← 姿态:.pose-walk / .pose-hop / .pose-fly / .pose-perch(pet-pose)
+        └── #pet-img
+  └── #chest(脆弱热区:胸部 hover 判定)
+  └── #bubble
+```
+
+## 4. 组件设计
+
+### 4.1 Rust `locomotion.rs`(新)
+
+- 循环间隔 60ms。
+- 模式机:`Idle`(静立)→ 随机触发 `AutoWalk`/`Hop`;`Perch`(跟随)由命令进入;抖动触发 `Fly`。
+- 移动窗口:维护当前窗口物理坐标,按步进趋近目标;`Perch` 时直接 `窗口 = 光标 - 锚点`(锚点 = 宠物双脚在窗口内的坐标 ≈ (100, 200))。
+- 边界:一律 clamp 到 `current_monitor().work_area()`,保证整窗可见。
+- 抖动检测:保存最近 N 个光标采样点(位置+时间),在 ~600ms 窗口内统计方向反转次数与总位移;满足阈值 → `Fly`。
+- `Fly`:约 600ms 内插值移动到工作区内**随机目标点**,落地回 `Idle`。
+- 事件:`pet://gait {kind, direction, phase}`(kind ∈ idle/walk/hop/fly/perch)。
+- 事件:`pet://perch {active}`(进入/退出跟随,供前端同步"是否站手")。
+
+### 4.2 Rust `state.rs`(纯函数 + 单测)
+
+新增可单测的纯逻辑(不依赖 tauri 运行时):
+
+- `detect_shake(samples, now) -> bool`:方向反转 + 位移阈值。
+- `clamp_window_pos(pos, work_area, win_size) -> (i32,i32)`:把窗口左上角夹进工作区。
+- `pick_random_target(work_area, win_size, seed) -> (i32,i32)`:工作区内的随机落点(用简单 LCG,便于测试确定性)。
+
+### 4.3 Rust `app_state.rs` / `commands.rs` / `main.rs`
+
+- `AppState` 增 `follow_cursor: AtomicBool`(命令与循环共享)。
+- 命令 `start_perch`(胸部悬停满 5s 时前端调用)→ 置 `follow_cursor=true`,emit `pet://gait {kind:"perch"}` + `pet://perch {active:true}`。
+- 命令 `stop_perch` → 置 false,emit `pet://perch {active:false}`。
+- `main.rs`:注册新命令;`setup` 内 `locomotion::spawn(...)` 启动循环(与 `poller::spawn` 并列)。
+
+### 4.4 JS
+
+- `index.html`:新增 `#pet-body` 包装、`#chest` 热区;`#pet { cursor: pointer }`(手指);引入 `pet-pose.js`;`#chest` 定位在鹦鹉胸部(上方中区)。
+- `animations.css`:新增 `@keyframes walk_bob / hop / fly_flutter / perch_lean` 与 `.pose-*` 类。
+- `pet-pose.js`(新):监听 `pet://gait` 切换 `#pet-body` 的姿态类;监听 `pet://perch` 设置 `window.__petPerched`。
+- `bridge.js`:`cfcHost` 增 `startPerch()` / `stopPerch()` → `invoke('start_perch'|'stop_perch')`。
+- `interactions.js`:
+  - `#chest` `mouseenter` 起 5s 计时 → `cfcHost.startPerch()`;`mouseleave` 前取消;成功后面板气泡「飞到你手上~」。
+  - `__petPerched` 为真时跳过 `drag_start`(避免跟随与原生拖拽打架)。
+
+## 5. 交互映射
+
+| 操作 | 结果 |
+|---|---|
+| 空闲 | 偶发 `walk` / `hop`(窗口横移;hop 带弹跳) |
+| 鼠标移到鹦鹉上 | 光标变手指(pointer) |
+| 悬停胸部 5 秒 | 迈到"手指"上 → 跟随鼠标(窗口跟随光标,双脚落在光标处) |
+| 跟随中抖动鼠标 | `fly` → 飞到当前显示器工作区随机点 → 落地回 idle |
+
+## 6. 边界与冲突
+
+- 多屏/边界:一律以 `current_monitor()` 工作区为准 clamp。
+- 「胸部悬停」用**纯 hover**(不按键),与「按住拖拽」互不冲突;站手期间禁用原生拖拽。
+- 窗口保持置顶;跟随由 Rust 轮询驱动,不依赖窗口事件,避免光标快速移动导致丢失。
+- `set_ignore_cursor_events`(鼠标穿透)与移动窗口互不影响。
+
+## 7. 验收
+
+- Rust:`cargo test` 覆盖 `detect_shake` / `clamp_window_pos` / `pick_random_target`,全绿。
+- Rust:`cargo check --all-targets` 通过。
+- JS:全部 `.js` `node --check` 通过。
+- 画面:由用户运行新构建 exe 肉眼确认(本机 WebView2 CDP 不可用,无法自动截图)。
+
+## 8. 非目标(本轮)
+
+- 真实多帧精灵动画(待美术素材到位后,按 `pet_config.animations.frames` 接入)。
+- macOS 多屏/Retina 坐标细化。
+- 宠物沿任意曲线路径行走(本轮为水平步进 + 弹跳)。