소스 검색

农场摘取功能

dongasai 6 달 전
부모
커밋
e66c69ab64

+ 112 - 0
app/Module/Farm/Commands/TestPickFunctionCommand.php

@@ -0,0 +1,112 @@
+<?php
+
+namespace App\Module\Farm\Commands;
+
+use App\Module\Farm\Services\PickService;
+use App\Module\Farm\Models\FarmCrop;
+use App\Module\Farm\Models\FarmSeed;
+use App\Module\Farm\Models\FarmLand;
+use App\Module\Farm\Models\FarmUser;
+use App\Module\Farm\Enums\GROWTH_STAGE;
+use Illuminate\Console\Command;
+
+/**
+ * 测试摘取功能命令
+ */
+class TestPickFunctionCommand extends Command
+{
+    /**
+     * 命令签名
+     *
+     * @var string
+     */
+    protected $signature = 'farm:test-pick-function';
+
+    /**
+     * 命令描述
+     *
+     * @var string
+     */
+    protected $description = '测试农场摘取功能';
+
+    /**
+     * 执行命令
+     *
+     * @return int
+     */
+    public function handle(): int
+    {
+        $this->info('开始测试农场摘取功能...');
+
+        try {
+            // 测试获取摘取信息
+            $this->testGetPickInfo();
+
+            // 测试检查摘取条件
+            $this->testCanPickCrop();
+
+            $this->info('所有测试通过!');
+            return 0;
+        } catch (\Exception $e) {
+            $this->error('测试失败: ' . $e->getMessage());
+            $this->error('堆栈跟踪: ' . $e->getTraceAsString());
+            return 1;
+        }
+    }
+
+    /**
+     * 测试获取摘取信息
+     */
+    private function testGetPickInfo(): void
+    {
+        $this->info('测试获取摘取信息...');
+
+        // 查找一个成熟的作物
+        $crop = FarmCrop::where('growth_stage', GROWTH_STAGE::MATURE)
+            ->where('final_output_amount', '>', 0)
+            ->first();
+
+        if (!$crop) {
+            $this->warn('没有找到成熟的作物,跳过测试');
+            return;
+        }
+
+        $pickInfo = PickService::getPickInfo($crop->id);
+
+        if ($pickInfo) {
+            $this->info("作物ID: {$pickInfo->cropId}");
+            $this->info("总产量: {$pickInfo->totalAmount}");
+            $this->info("已摘取: {$pickInfo->pickedAmount}");
+            $this->info("可摘取: {$pickInfo->pickableAmount}");
+            $this->info("是否可摘取: " . ($pickInfo->canPick ? '是' : '否'));
+        } else {
+            $this->warn('获取摘取信息失败');
+        }
+    }
+
+    /**
+     * 测试检查摘取条件
+     */
+    private function testCanPickCrop(): void
+    {
+        $this->info('测试检查摘取条件...');
+
+        // 查找一个作物
+        $crop = FarmCrop::first();
+
+        if (!$crop) {
+            $this->warn('没有找到作物,跳过测试');
+            return;
+        }
+
+        $result = PickService::canPickCrop($crop->id);
+
+        $this->info("作物ID: {$crop->id}");
+        $this->info("是否可摘取: " . ($result['can_pick'] ? '是' : '否'));
+        $this->info("原因: {$result['reason']}");
+        
+        if (isset($result['pickable_amount'])) {
+            $this->info("可摘取数量: {$result['pickable_amount']}");
+        }
+    }
+}

+ 23 - 75
app/Module/Farm/Docs/摘取.md

@@ -13,11 +13,10 @@
 9. [配置设计](#9-配置设计)
 10. [事件系统](#10-事件系统)
 11. [使用示例](#11-使用示例)
-12. [Handler层设计](#12-handler层设计)
-13. [作物日志扩展](#13-作物日志扩展)
-14. [植物配置表扩展](#14-植物配置表扩展)
-15. [数据库迁移SQL](#15-数据库迁移sql)
-16. [总结](#16-总结)
+12. [作物日志扩展](#12-作物日志扩展)
+13. [植物配置表扩展](#13-植物配置表扩展)
+14. [数据库迁移SQL](#14-数据库迁移sql)
+15. [总结](#15-总结)
 
 ## 1. 功能概述
 
@@ -288,6 +287,7 @@ const EVENT_PICKED = 'picked';  // 摘取事件
 3. 记录摘取来源和来源ID到日志
 4. 返回成功结果或错误信息
 5. 记录详细的操作日志
+
 **其他服务方法**:
 
 **getPickInfo() 方法**:
@@ -299,17 +299,18 @@ const EVENT_PICKED = 'picked';  // 摘取事件
 - 在事务中执行批量摘取操作
 - 调用逻辑层的批量摘取方法
 - 统一的异常处理和错误返回
+
 **canPickCrop() 方法**:
 - 根据作物ID检查摘取条件
 - 检查作物状态和摘取限制
 - 无需验证用户身份或权限关系
 - 返回详细的检查结果和原因
+
 **getPickHistory() 方法**:
 - 从作物日志表查询摘取事件记录
 - 支持按摘取者或农场主身份查询
 - 通过JSON字段查询摘取者信息
 - 返回格式化的摘取历史数组
-```
 
 ## 9. 配置设计
 
@@ -435,62 +436,9 @@ $cropRequests = [
 **检查项目**:作物状态、摘取限制、冷却时间等(无需权限检查)
 **返回信息**:是否可摘取及详细原因
 
-## 12. Handler层设计
-
-### 12.1 摘取Handler
-
-**PickHandler 类设计**:
-
-**处理流程**:
-1. 获取请求参数(作物ID、摘取数量、来源信息等)
-2. 确定摘取来源和来源ID
-3. 执行数据验证
-4. 开启数据库事务
-5. 调用摘取服务执行操作
-6. 设置protobuf响应数据
-7. 记录操作日志
-
-**响应字段**:
-- `success`: 操作是否成功
-- `cropId`: 作物ID
-- `itemId`: 摘取的物品ID
-- `pickAmount`: 摘取数量
-- `remainingAmount`: 剩余可摘取数量
-- `pickTime`: 摘取时间
-- `pickSource`: 摘取来源
-- `sourceId`: 来源ID
-- `canPickAgain`: 是否可以再次摘取
-- `nextPickTime`: 下次可摘取时间(可选)
-- `errorMessage`: 错误信息(失败时)
-
-### 12.2 获取摘取信息Handler
-
-**PickInfoHandler 类设计**:
-
-**处理流程**:
-1. 获取作物ID参数
-2. 调用摘取服务获取摘取信息
-3. 设置protobuf响应数据
-4. 异常处理和日志记录
-
-**响应字段**:
-- `success`: 操作是否成功
-- `cropId`: 作物ID
-- `seedId`: 种子ID
-- `totalAmount`: 总产出数量
-- `pickedAmount`: 已摘取数量
-- `pickableAmount`: 可摘取数量
-- `minReserveAmount`: 最小保留数量
-- `canPick`: 是否可以摘取
-- `pickCount`: 摘取次数
-- `maxPickRatio`: 最大摘取比例
-- `lastPickTime`: 最后摘取时间(可选)
-- `nextPickTime`: 下次可摘取时间(可选)
-- `errorMessage`: 错误信息(失败时)
-
-## 13. 作物日志扩展
+## 12. 作物日志扩展
 
-### 13.1 在FarmCropLog模型中添加摘取事件
+### 12.1 在FarmCropLog模型中添加摘取事件
 
 **需要添加的内容**:
 - 摘取事件常量:`EVENT_PICKED = 'picked'`
@@ -511,9 +459,9 @@ $cropRequests = [
 - `ip_address`: 摘取者IP
 - `user_agent`: 用户代理
 
-## 14. 植物配置表扩展
+## 13. 植物配置表扩展
 
-### 14.1 种子配置表摘取限制扩展
+### 13.1 种子配置表摘取限制扩展
 
 **表名**:`farm_seeds`
 
@@ -529,7 +477,7 @@ $cropRequests = [
 - `pick_min_reserve_ratio`: 必须保留的最小比例(如0.1表示至少保留10%)
 - `pick_cooldown_seconds`: 摘取后的冷却时间(秒)
 
-### 14.2 果实生长周期表摘取限制扩展
+### 13.2 果实生长周期表摘取限制扩展
 
 **表名**:`farm_fruit_growth_cycles`
 
@@ -545,9 +493,9 @@ $cropRequests = [
 
 
 
-## 15. 数据库迁移SQL
+## 14. 数据库迁移SQL
 
-### 15.1 作物表字段扩展SQL
+### 14.1 作物表字段扩展SQL
 
 ```sql
 -- 为作物表添加摘取相关字段
@@ -564,7 +512,7 @@ ADD INDEX `idx_pick_cooldown_end` (`pick_cooldown_end`),
 ADD INDEX `idx_picked_amount` (`picked_amount`);
 ```
 
-### 15.2 种子配置表摘取限制扩展SQL
+### 14.2 种子配置表摘取限制扩展SQL
 
 ```sql
 -- 为种子配置表添加摘取限制字段
@@ -580,7 +528,7 @@ ADD INDEX `idx_pick_enabled` (`pick_enabled`),
 ADD INDEX `idx_pick_max_ratio` (`pick_max_ratio`);
 ```
 
-### 15.3 果实生长周期表摘取限制扩展SQL
+### 14.3 果实生长周期表摘取限制扩展SQL
 
 ```sql
 -- 为果实生长周期表添加摘取限制字段
@@ -595,7 +543,7 @@ ADD INDEX `idx_pick_enabled` (`pick_enabled`),
 ADD INDEX `idx_pick_start_stage` (`pick_start_stage`);
 ```
 
-### 15.4 作物日志表事件类型更新SQL
+### 14.4 作物日志表事件类型更新SQL
 
 ```sql
 -- 更新作物日志表的事件类型注释,添加摘取事件
@@ -609,9 +557,9 @@ ADD INDEX `idx_event_type_user` (`event_type`, `user_id`),
 ADD INDEX `idx_event_type_created` (`event_type`, `created_at`);
 ```
 
-## 16. 总结
+## 15. 总结
 
-### 16.1 功能特点
+### 15.1 功能特点
 
 摘取功能作为农场模块的核心功能,具有以下特点:
 
@@ -622,7 +570,7 @@ ADD INDEX `idx_event_type_created` (`event_type`, `created_at`);
 5. **灵活性**:支持多种摘取来源,适应不同业务场景
 6. **安全性**:通过验证层和事务机制保证数据安全
 
-### 16.2 与收获功能的区别
+### 15.2 与收获功能的区别
 
 | 特性 | 摘取功能 | 收获功能 |
 |------|---------|---------|
@@ -633,7 +581,7 @@ ADD INDEX `idx_event_type_created` (`event_type`, `created_at`);
 | 土地状态 | 不变 | 变为枯萎状态 |
 | 后续操作 | 可继续摘取或收获 | 需要重新种植 |
 
-### 16.3 实现要点
+### 15.3 实现要点
 
 1. **数据库设计**:扩展作物表字段,利用现有作物日志表记录摘取事件
 2. **植物配置**:在种子配置表和果实生长周期表中添加摘取限制参数
@@ -645,7 +593,7 @@ ADD INDEX `idx_event_type_created` (`event_type`, `created_at`);
 8. **来源追溯**:完整记录摘取来源和来源ID,支持业务追溯
 9. **模块边界**:明确农场模块职责,不包含好友关系、通知等其他模块逻辑
 
-### 16.4 模块协作
+### 15.4 模块协作
 
 农场模块通过事件系统与其他模块协作:
 
@@ -655,7 +603,7 @@ ADD INDEX `idx_event_type_created` (`event_type`, `created_at`);
 4. **统计系统**:监听摘取事件,进行用户行为分析和数据统计
 5. **防护系统**:可以在摘取前进行额外的防护检查
 
-### 16.5 设计原则
+### 15.5 设计原则
 
 1. **单一职责**:农场模块只负责作物摘取的核心功能
 2. **开放封闭**:通过事件系统支持扩展,核心逻辑保持稳定

+ 64 - 0
app/Module/Farm/Dtos/PickInfoDto.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Module\Farm\Dtos;
+
+use UCore\Dto\BaseDto;
+use App\Module\Farm\Models\FarmCrop;
+use Carbon\Carbon;
+
+/**
+ * 摘取信息DTO
+ * 
+ * @property int $cropId 作物ID
+ * @property int $seedId 种子ID
+ * @property int $totalAmount 总产出数量
+ * @property int $pickedAmount 已摘取数量
+ * @property int $pickableAmount 可摘取数量
+ * @property int $minReserveAmount 最小保留数量
+ * @property bool $canPick 是否可以摘取
+ * @property Carbon|null $lastPickTime 最后摘取时间
+ * @property Carbon|null $nextPickTime 下次可摘取时间
+ * @property int $pickCount 摘取次数
+ * @property float $maxPickRatio 最大摘取比例
+ */
+class PickInfoDto extends BaseDto
+{
+    public int $cropId;
+    public int $seedId;
+    public int $totalAmount;
+    public int $pickedAmount;
+    public int $pickableAmount;
+    public int $minReserveAmount;
+    public bool $canPick;
+    public ?Carbon $lastPickTime;
+    public ?Carbon $nextPickTime;
+    public int $pickCount;
+    public float $maxPickRatio;
+
+    /**
+     * 从作物模型创建DTO
+     *
+     * @param FarmCrop $crop 作物模型
+     * @return static
+     */
+    public static function fromCrop(FarmCrop $crop): static
+    {
+        $dto = new static();
+        
+        $dto->cropId = $crop->id;
+        $dto->seedId = $crop->seed_id;
+        $dto->totalAmount = $crop->final_output_amount;
+        $dto->pickedAmount = $crop->picked_amount;
+        $dto->pickableAmount = $crop->pickable_amount;
+        $dto->minReserveAmount = $crop->min_reserve_amount;
+        $dto->canPick = $crop->canBePicked();
+        $dto->lastPickTime = $crop->last_pick_time;
+        $dto->nextPickTime = $crop->pick_cooldown_end;
+        $dto->pickCount = $crop->pick_count;
+        
+        // 从种子配置获取最大摘取比例,默认为0.3(30%)
+        $dto->maxPickRatio = $crop->seed->pick_max_ratio ?? 0.3;
+
+        return $dto;
+    }
+}

+ 67 - 0
app/Module/Farm/Dtos/PickResultDto.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Module\Farm\Dtos;
+
+use UCore\Dto\BaseDto;
+use Carbon\Carbon;
+
+/**
+ * 摘取结果DTO
+ * 
+ * @property int $cropId 作物ID
+ * @property int $itemId 摘取的物品ID
+ * @property int $pickAmount 摘取数量
+ * @property int $remainingAmount 剩余可摘取数量
+ * @property float $pickRatio 摘取比例
+ * @property Carbon $pickTime 摘取时间
+ * @property string $pickSource 摘取来源
+ * @property int|null $sourceId 来源ID
+ * @property int $pickLogId 摘取记录ID(作物日志ID)
+ * @property bool $canPickAgain 是否可以再次摘取
+ * @property Carbon|null $nextPickTime 下次可摘取时间
+ */
+class PickResultDto extends BaseDto
+{
+    public int $cropId;
+    public int $itemId;
+    public int $pickAmount;
+    public int $remainingAmount;
+    public float $pickRatio;
+    public Carbon $pickTime;
+    public string $pickSource;
+    public ?int $sourceId;
+    public int $pickLogId;
+    public bool $canPickAgain;
+    public ?Carbon $nextPickTime;
+
+    /**
+     * 从摘取操作创建DTO
+     *
+     * @param array $pickData 摘取操作数据
+     * @return static
+     */
+    public static function fromPickOperation(array $pickData): static
+    {
+        $dto = new static();
+        
+        $dto->cropId = $pickData['crop_id'];
+        $dto->itemId = $pickData['item_id'];
+        $dto->pickAmount = $pickData['pick_amount'];
+        $dto->remainingAmount = $pickData['remaining_amount'];
+        $dto->pickRatio = $pickData['pick_ratio'];
+        $dto->pickTime = $pickData['pick_time'] instanceof Carbon 
+            ? $pickData['pick_time'] 
+            : Carbon::parse($pickData['pick_time']);
+        $dto->pickSource = $pickData['pick_source'];
+        $dto->sourceId = $pickData['source_id'] ?? null;
+        $dto->pickLogId = $pickData['pick_log_id'];
+        $dto->canPickAgain = $pickData['can_pick_again'];
+        $dto->nextPickTime = isset($pickData['next_pick_time']) 
+            ? ($pickData['next_pick_time'] instanceof Carbon 
+                ? $pickData['next_pick_time'] 
+                : Carbon::parse($pickData['next_pick_time']))
+            : null;
+
+        return $dto;
+    }
+}

+ 156 - 0
app/Module/Farm/Events/CropPickedEvent.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Module\Farm\Events;
+
+use App\Module\Farm\Models\FarmLand;
+use App\Module\Farm\Models\FarmCrop;
+use App\Module\Farm\Models\FarmCropLog;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+/**
+ * 作物摘取事件
+ * 当作物被摘取时触发此事件
+ */
+class CropPickedEvent
+{
+    use Dispatchable, SerializesModels;
+
+    /**
+     * 摘取者ID
+     *
+     * @var int
+     */
+    public int $pickerId;
+
+    /**
+     * 农场主ID(通过作物获取)
+     *
+     * @var int
+     */
+    public int $ownerId;
+
+    /**
+     * 土地ID(通过作物获取)
+     *
+     * @var int
+     */
+    public int $landId;
+
+    /**
+     * 作物ID(摘取目标)
+     *
+     * @var int
+     */
+    public int $cropId;
+
+    /**
+     * 摘取的物品ID
+     *
+     * @var int
+     */
+    public int $itemId;
+
+    /**
+     * 摘取数量
+     *
+     * @var int
+     */
+    public int $pickAmount;
+
+    /**
+     * 摘取前总数量
+     *
+     * @var int
+     */
+    public int $originalAmount;
+
+    /**
+     * 摘取后剩余数量
+     *
+     * @var int
+     */
+    public int $remainingAmount;
+
+    /**
+     * 摘取来源
+     *
+     * @var string
+     */
+    public string $pickSource;
+
+    /**
+     * 来源ID
+     *
+     * @var int|null
+     */
+    public ?int $sourceId;
+
+    /**
+     * 土地信息对象
+     *
+     * @var FarmLand
+     */
+    public FarmLand $land;
+
+    /**
+     * 作物信息对象
+     *
+     * @var FarmCrop
+     */
+    public FarmCrop $crop;
+
+    /**
+     * 作物日志记录对象
+     *
+     * @var FarmCropLog
+     */
+    public FarmCropLog $cropLog;
+
+    /**
+     * 创建新的事件实例
+     *
+     * @param int $pickerId 摘取者ID
+     * @param int $ownerId 农场主ID
+     * @param int $landId 土地ID
+     * @param int $cropId 作物ID
+     * @param int $itemId 摘取的物品ID
+     * @param int $pickAmount 摘取数量
+     * @param int $originalAmount 摘取前总数量
+     * @param int $remainingAmount 摘取后剩余数量
+     * @param string $pickSource 摘取来源
+     * @param int|null $sourceId 来源ID
+     * @param FarmLand $land 土地信息对象
+     * @param FarmCrop $crop 作物信息对象
+     * @param FarmCropLog $cropLog 作物日志记录对象
+     */
+    public function __construct(
+        int $pickerId,
+        int $ownerId,
+        int $landId,
+        int $cropId,
+        int $itemId,
+        int $pickAmount,
+        int $originalAmount,
+        int $remainingAmount,
+        string $pickSource,
+        ?int $sourceId,
+        FarmLand $land,
+        FarmCrop $crop,
+        FarmCropLog $cropLog
+    ) {
+        $this->pickerId = $pickerId;
+        $this->ownerId = $ownerId;
+        $this->landId = $landId;
+        $this->cropId = $cropId;
+        $this->itemId = $itemId;
+        $this->pickAmount = $pickAmount;
+        $this->originalAmount = $originalAmount;
+        $this->remainingAmount = $remainingAmount;
+        $this->pickSource = $pickSource;
+        $this->sourceId = $sourceId;
+        $this->land = $land;
+        $this->crop = $crop;
+        $this->cropLog = $cropLog;
+    }
+}

+ 106 - 0
app/Module/Farm/Listeners/PickStatisticsListener.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Module\Farm\Listeners;
+
+use App\Module\Farm\Events\CropPickedEvent;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 摘取统计监听器
+ * 处理摘取事件的统计和日志记录
+ */
+class PickStatisticsListener
+{
+    /**
+     * 处理摘取事件
+     *
+     * @param CropPickedEvent $event
+     * @return void
+     */
+    public function handle(CropPickedEvent $event): void
+    {
+        try {
+            // 更新摘取统计数据
+            $this->updatePickStatistics($event);
+
+            // 记录详细的摘取行为日志
+            $this->logPickBehavior($event);
+
+            // 触发其他模块的相关逻辑
+            $this->triggerExternalLogic($event);
+        } catch (\Exception $e) {
+            Log::error('摘取统计监听器处理失败', [
+                'event' => [
+                    'picker_id' => $event->pickerId,
+                    'crop_id' => $event->cropId,
+                    'pick_amount' => $event->pickAmount,
+                ],
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * 更新摘取统计信息
+     *
+     * @param CropPickedEvent $event
+     * @return void
+     */
+    private function updatePickStatistics(CropPickedEvent $event): void
+    {
+        // 这里可以实现摘取统计逻辑
+        // 例如:更新用户摘取次数、摘取总量等统计数据
+        
+        Log::info('摘取统计更新', [
+            'picker_id' => $event->pickerId,
+            'owner_id' => $event->ownerId,
+            'crop_id' => $event->cropId,
+            'pick_amount' => $event->pickAmount,
+            'item_id' => $event->itemId,
+            'pick_source' => $event->pickSource,
+        ]);
+    }
+
+    /**
+     * 记录摘取行为日志
+     *
+     * @param CropPickedEvent $event
+     * @return void
+     */
+    private function logPickBehavior(CropPickedEvent $event): void
+    {
+        Log::info('作物摘取行为记录', [
+            'picker_id' => $event->pickerId,
+            'owner_id' => $event->ownerId,
+            'land_id' => $event->landId,
+            'crop_id' => $event->cropId,
+            'item_id' => $event->itemId,
+            'pick_amount' => $event->pickAmount,
+            'remaining_amount' => $event->remainingAmount,
+            'pick_source' => $event->pickSource,
+            'source_id' => $event->sourceId,
+            'crop_log_id' => $event->cropLog->id,
+            'timestamp' => now()->toDateTimeString(),
+        ]);
+    }
+
+    /**
+     * 触发外部模块逻辑
+     *
+     * @param CropPickedEvent $event
+     * @return void
+     */
+    private function triggerExternalLogic(CropPickedEvent $event): void
+    {
+        // 这里可以触发其他模块的业务逻辑
+        // 例如:好友系统通知、奖励系统处理、防护系统检查等
+        
+        // 示例:记录触发的外部逻辑
+        Log::debug('摘取事件触发外部逻辑', [
+            'picker_id' => $event->pickerId,
+            'owner_id' => $event->ownerId,
+            'pick_source' => $event->pickSource,
+            'available_for_external_modules' => true,
+        ]);
+    }
+}

+ 237 - 0
app/Module/Farm/Logics/PickLogic.php

@@ -0,0 +1,237 @@
+<?php
+
+namespace App\Module\Farm\Logics;
+
+use App\Module\Farm\Models\FarmCrop;
+use App\Module\Farm\Models\FarmCropLog;
+use App\Module\Farm\Dtos\PickResultDto;
+use App\Module\Farm\Dtos\PickInfoDto;
+use App\Module\Farm\Validators\PickableStatusValidator;
+use App\Module\Farm\Validators\PickAmountValidator;
+use App\Module\Farm\Validators\PickCooldownValidator;
+use App\Module\Farm\Validators\PickSourceValidator;
+use App\Module\Farm\Events\CropPickedEvent;
+use App\Module\GameItems\Services\ItemService;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 摘取逻辑类
+ * 处理作物摘取的核心业务逻辑
+ */
+class PickLogic
+{
+    /**
+     * 执行摘取操作的核心方法
+     *
+     * @param int $pickerId 摘取者ID
+     * @param int $cropId 作物ID
+     * @param int $pickAmount 摘取数量
+     * @param string $pickSource 摘取来源
+     * @param int|null $sourceId 来源ID
+     * @return PickResultDto
+     * @throws \Exception
+     */
+    public function executePick(int $pickerId, int $cropId, int $pickAmount, string $pickSource, ?int $sourceId = null): PickResultDto
+    {
+        // 事务检查确保在事务中执行
+        if (!DB::transactionLevel()) {
+            throw new \Exception('摘取操作必须在数据库事务中执行');
+        }
+
+        // 验证摘取状态
+        $statusValidator = new PickableStatusValidator();
+        if (!$statusValidator->validate($cropId)) {
+            throw new \Exception($statusValidator->getFirstError());
+        }
+
+        $crop = $statusValidator->getCrop();
+
+        // 验证摘取数量
+        $amountValidator = new PickAmountValidator();
+        if (!$amountValidator->validate($crop, $pickAmount)) {
+            throw new \Exception($amountValidator->getFirstError());
+        }
+
+        // 验证冷却时间
+        $cooldownValidator = new PickCooldownValidator();
+        if (!$cooldownValidator->validate($crop)) {
+            throw new \Exception($cooldownValidator->getFirstError());
+        }
+
+        // 验证摘取来源
+        $sourceValidator = new PickSourceValidator();
+        if (!$sourceValidator->validate($pickSource, $sourceId)) {
+            throw new \Exception($sourceValidator->getFirstError());
+        }
+
+        // 计算摘取比例
+        $pickRatio = $pickAmount / $crop->final_output_amount;
+
+        // 获取冷却时间配置
+        $cooldownSeconds = $crop->seed->pick_cooldown_seconds ?? 1800; // 默认30分钟
+
+        // 更新作物的摘取相关字段
+        $crop->picked_amount += $pickAmount;
+        $crop->last_pick_time = now();
+        $crop->pick_cooldown_end = now()->addSeconds($cooldownSeconds);
+        $crop->save();
+
+        // 记录摘取事件到作物日志
+        $eventData = [
+            'picker_id' => $pickerId,
+            'pick_amount' => $pickAmount,
+            'remaining_amount' => $crop->pickable_amount,
+            'pick_ratio' => $pickRatio,
+            'pick_source' => $pickSource,
+            'source_id' => $sourceId,
+            'item_id' => $crop->final_output_item_id,
+            'total_picked' => $crop->picked_amount,
+            'cooldown_end' => $crop->pick_cooldown_end->toDateTimeString(),
+            'ip_address' => request()->ip(),
+            'user_agent' => request()->userAgent(),
+            'growth_stage' => $crop->growth_stage->value,
+            'land_type' => $crop->land->land_type ?? 1,
+        ];
+
+        $cropLog = FarmCropLog::logPicked(
+            $crop->user_id,
+            $crop->land_id,
+            $crop->id,
+            $crop->seed_id,
+            $eventData
+        );
+
+        // 给摘取者添加物品到背包
+        ItemService::addItem($pickerId, $crop->final_output_item_id, $pickAmount, $pickSource, $sourceId);
+
+        // 触发摘取事件
+        event(new CropPickedEvent(
+            $pickerId,
+            $crop->user_id,
+            $crop->land_id,
+            $crop->id,
+            $crop->final_output_item_id,
+            $pickAmount,
+            $crop->final_output_amount,
+            $crop->pickable_amount,
+            $pickSource,
+            $sourceId,
+            $crop->land,
+            $crop,
+            $cropLog
+        ));
+
+        // 记录操作日志
+        Log::info('作物摘取成功', [
+            'picker_id' => $pickerId,
+            'crop_id' => $cropId,
+            'pick_amount' => $pickAmount,
+            'pick_source' => $pickSource,
+            'source_id' => $sourceId,
+            'crop_log_id' => $cropLog->id,
+        ]);
+
+        // 返回摘取结果DTO
+        return PickResultDto::fromPickOperation([
+            'crop_id' => $crop->id,
+            'item_id' => $crop->final_output_item_id,
+            'pick_amount' => $pickAmount,
+            'remaining_amount' => $crop->pickable_amount,
+            'pick_ratio' => $pickRatio,
+            'pick_time' => $crop->last_pick_time,
+            'pick_source' => $pickSource,
+            'source_id' => $sourceId,
+            'pick_log_id' => $cropLog->id,
+            'can_pick_again' => $crop->pickable_amount > 0,
+            'next_pick_time' => $crop->pick_cooldown_end,
+        ]);
+    }
+
+    /**
+     * 获取作物摘取信息
+     *
+     * @param int $cropId 作物ID
+     * @return PickInfoDto|null
+     */
+    public function getPickInfo(int $cropId): ?PickInfoDto
+    {
+        $crop = FarmCrop::find($cropId);
+        if (!$crop) {
+            return null;
+        }
+
+        return PickInfoDto::fromCrop($crop);
+    }
+
+    /**
+     * 批量摘取多个作物
+     *
+     * @param int $pickerId 摘取者ID
+     * @param array $cropRequests 摘取请求数组
+     * @return array
+     */
+    public function batchPick(int $pickerId, array $cropRequests): array
+    {
+        $results = [];
+
+        foreach ($cropRequests as $request) {
+            try {
+                $result = $this->executePick(
+                    $pickerId,
+                    $request['crop_id'],
+                    $request['amount'],
+                    $request['source'],
+                    $request['source_id'] ?? null
+                );
+                
+                $results[] = [
+                    'success' => true,
+                    'crop_id' => $request['crop_id'],
+                    'result' => $result,
+                ];
+            } catch (\Exception $e) {
+                $results[] = [
+                    'success' => false,
+                    'crop_id' => $request['crop_id'],
+                    'error' => $e->getMessage(),
+                ];
+                
+                Log::warning('批量摘取失败', [
+                    'picker_id' => $pickerId,
+                    'crop_id' => $request['crop_id'],
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $results;
+    }
+
+    /**
+     * 格式化摘取来源信息用于日志记录
+     *
+     * @param string $pickSource 摘取来源
+     * @param int|null $sourceId 来源ID
+     * @return string
+     */
+    private function formatPickSource(string $pickSource, ?int $sourceId = null): string
+    {
+        $sourceNames = [
+            'manual' => '手动摘取',
+            'friend_visit' => '好友访问',
+            'system_auto' => '系统自动',
+            'task_reward' => '任务奖励',
+            'event_bonus' => '活动奖励',
+        ];
+
+        $sourceName = $sourceNames[$pickSource] ?? $pickSource;
+        
+        if ($sourceId) {
+            return "{$sourceName}(ID:{$sourceId})";
+        }
+
+        return $sourceName;
+    }
+}

+ 75 - 0
app/Module/Farm/Models/FarmCrop.php

@@ -28,6 +28,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property  bool  $can_disaster  当前阶段是否可以产生灾害:0否,1是
  * @property  int  $final_output_item_id  最终产出果实ID(发芽期确定)
  * @property  int  $final_output_amount  最终产出数量(成熟期确定)
+ * @property  int  $picked_amount  已摘取数量
+ * @property  \Carbon\Carbon  $last_pick_time  最后摘取时间
+ * @property  \Carbon\Carbon  $pick_cooldown_end  摘取冷却结束时间
+ * @property  int  $min_reserve_amount  最小保留数量(不可摘取)
  * @property  \Carbon\Carbon  $created_at  创建时间
  * @property  \Carbon\Carbon  $updated_at  更新时间
  * @property  \Carbon\Carbon  $deleted_at  删除时间
@@ -66,6 +70,10 @@ class FarmCrop extends Model
         'can_disaster',
         'final_output_item_id',
         'final_output_amount',
+        'picked_amount',
+        'last_pick_time',
+        'pick_cooldown_end',
+        'min_reserve_amount',
     ];
 
 
@@ -83,6 +91,8 @@ class FarmCrop extends Model
         'stage_end_time'           => 'datetime',
         'last_disaster_check_time' => 'datetime',
         'can_disaster'             => 'boolean',
+        'last_pick_time'           => 'datetime',
+        'pick_cooldown_end'        => 'datetime',
         'deleted_at'               => 'datetime',
     ];
 
@@ -119,6 +129,71 @@ class FarmCrop extends Model
     public function final_output_item():HasOne
     {
         return $this->hasOne(Item::class, 'id', 'final_output_item_id');
+    }
+
+    /**
+     * 获取可摘取数量的访问器
+     * 可摘取数量 = 总产量 - 已摘取数量 - 最小保留数量
+     *
+     * @return int
+     */
+    public function getPickableAmountAttribute(): int
+    {
+        if ($this->final_output_amount <= 0) {
+            return 0;
+        }
+
+        $pickable = $this->final_output_amount - $this->picked_amount - $this->min_reserve_amount;
+        return max(0, $pickable);
+    }
+
+    /**
+     * 检查是否可以摘取
+     *
+     * @return bool
+     */
+    public function canBePicked(): bool
+    {
+        // 必须处于成熟期
+        if ($this->growth_stage !== GROWTH_STAGE::MATURE) {
+            return false;
+        }
+
+        // 必须有可摘取数量
+        if ($this->pickable_amount <= 0) {
+            return false;
+        }
 
+        // 检查冷却时间
+        if ($this->pick_cooldown_end && now() < $this->pick_cooldown_end) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 获取摘取记录的访问器(通过作物日志查询)
+     *
+     * @return \Illuminate\Database\Eloquent\Collection
+     */
+    public function getPickLogsAttribute()
+    {
+        return FarmCropLog::where('crop_id', $this->id)
+            ->where('event_type', FarmCropLog::EVENT_PICKED)
+            ->orderBy('created_at', 'desc')
+            ->get();
+    }
+
+    /**
+     * 获取摘取次数的访问器
+     *
+     * @return int
+     */
+    public function getPickCountAttribute(): int
+    {
+        return FarmCropLog::where('crop_id', $this->id)
+            ->where('event_type', FarmCropLog::EVENT_PICKED)
+            ->count();
     }
 }

+ 85 - 0
app/Module/Farm/Models/FarmCropLog.php

@@ -41,6 +41,7 @@ class FarmCropLog extends ModelCore
     const EVENT_WEEDICIDE_USED = 'weedicide_used';        // 使用除草剂
     const EVENT_WATERING = 'watering';                    // 浇水
     const EVENT_REMOVED = 'removed';                      // 铲除作物
+    const EVENT_PICKED = 'picked';                        // 摘取
 
     protected $fillable = [
         'user_id',
@@ -107,6 +108,7 @@ class FarmCropLog extends ModelCore
             self::EVENT_WEEDICIDE_USED => '使用除草剂',
             self::EVENT_WATERING => '浇水',
             self::EVENT_REMOVED => '铲除作物',
+            self::EVENT_PICKED => '摘取',
             default => '未知事件'
         };
     }
@@ -288,6 +290,23 @@ class FarmCropLog extends ModelCore
         ]);
     }
 
+    /**
+     * 静态方法:记录摘取事件
+     */
+    public static function logPicked(int $userId, int $landId, int $cropId, int $seedId, array $eventData): self
+    {
+        return self::create([
+            'user_id' => $userId,
+            'land_id' => $landId,
+            'crop_id' => $cropId,
+            'seed_id' => $seedId,
+            'event_type' => self::EVENT_PICKED,
+            'event_data' => $eventData,
+            'growth_stage' => $eventData['growth_stage'] ?? GROWTH_STAGE::MATURE->value,
+            'land_type' => $eventData['land_type'] ?? 1,
+        ]);
+    }
+
     /**
      * 获取可读的事件数据摘要
      *
@@ -312,6 +331,7 @@ class FarmCropLog extends ModelCore
             self::EVENT_WEEDICIDE_USED => $this->parseWeedicideUsedData($data),
             self::EVENT_WATERING => $this->parseWateringData($data),
             self::EVENT_REMOVED => $this->parseRemovedData($data),
+            self::EVENT_PICKED => $this->parsePickedData($data),
             default => '未知事件类型'
         };
     }
@@ -339,6 +359,7 @@ class FarmCropLog extends ModelCore
             self::EVENT_PESTICIDE_USED => $this->parsePesticideUsedDataDetail($data),
             self::EVENT_WEEDICIDE_USED => $this->parseWeedicideUsedDataDetail($data),
             self::EVENT_WATERING => $this->parseWateringDataDetail($data),
+            self::EVENT_PICKED => $this->parsePickedDataDetail($data),
             default => '<pre>' . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . '</pre>'
         };
     }
@@ -637,6 +658,40 @@ class FarmCropLog extends ModelCore
         return implode('<br>', $details);
     }
 
+    /**
+     * 解析摘取事件数据(摘要)
+     */
+    private function parsePickedData(array $data): string
+    {
+        $pickerId = $data['picker_id'] ?? '未知';
+        $pickAmount = $data['pick_amount'] ?? 0;
+        $itemId = $data['item_id'] ?? '未知';
+        $pickSource = $data['pick_source'] ?? '未知';
+
+        return "摘取者ID: {$pickerId}, 摘取数量: {$pickAmount}, 物品ID: {$itemId}, 来源: {$pickSource}";
+    }
+
+    /**
+     * 解析摘取事件数据(详细)
+     */
+    private function parsePickedDataDetail(array $data): string
+    {
+        $details = [];
+        $details[] = '<strong>摘取事件详情:</strong>';
+        $details[] = '• 摘取者ID: ' . ($data['picker_id'] ?? '未知');
+        $details[] = '• 摘取数量: ' . ($data['pick_amount'] ?? '未知');
+        $details[] = '• 摘取后剩余数量: ' . ($data['remaining_amount'] ?? '未知');
+        $details[] = '• 摘取比例: ' . (($data['pick_ratio'] ?? 0) * 100) . '%';
+        $details[] = '• 摘取来源: ' . ($data['pick_source'] ?? '未知');
+        $details[] = '• 来源ID: ' . ($data['source_id'] ?? '无');
+        $details[] = '• 摘取物品ID: ' . ($data['item_id'] ?? '未知');
+        $details[] = '• 累计摘取数量: ' . ($data['total_picked'] ?? '未知');
+        $details[] = '• 冷却结束时间: ' . ($data['cooldown_end'] ?? '未知');
+        $details[] = '• 摘取者IP: ' . ($data['ip_address'] ?? '未知');
+
+        return implode('<br>', $details);
+    }
+
     /**
      * 静态方法:解析事件数据摘要
      *
@@ -661,6 +716,7 @@ class FarmCropLog extends ModelCore
             self::EVENT_WEEDICIDE_USED => self::parseWeedicideUsedSummary($data),
             self::EVENT_WATERING => self::parseWateringSummary($data),
             self::EVENT_REMOVED => self::parseRemovedSummary($data),
+            self::EVENT_PICKED => self::parsePickedSummary($data),
             default => '未知事件类型'
         };
     }
@@ -689,6 +745,7 @@ class FarmCropLog extends ModelCore
             self::EVENT_WEEDICIDE_USED => self::parseWeedicideUsedDetail($data),
             self::EVENT_WATERING => self::parseWateringDetail($data),
             self::EVENT_REMOVED => self::parseRemovedDetail($data),
+            self::EVENT_PICKED => self::parsePickedDetail($data),
             default => '<pre>' . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . '</pre>'
         };
     }
@@ -780,6 +837,16 @@ class FarmCropLog extends ModelCore
         return "{$toolText}, 铲除时间: {$removedAt}";
     }
 
+    private static function parsePickedSummary(array $data): string
+    {
+        $pickerId = $data['picker_id'] ?? '未知';
+        $pickAmount = $data['pick_amount'] ?? 0;
+        $itemId = $data['item_id'] ?? '未知';
+        $pickSource = $data['pick_source'] ?? '未知';
+
+        return "摘取者ID: {$pickerId}, 摘取数量: {$pickAmount}, 物品ID: {$itemId}, 来源: {$pickSource}";
+    }
+
     // 静态解析方法 - 详情版本
     private static function parseFruitConfirmedDetail(array $data): string
     {
@@ -965,6 +1032,24 @@ class FarmCropLog extends ModelCore
         return implode('<br>', $details);
     }
 
+    private static function parsePickedDetail(array $data): string
+    {
+        $details = [];
+        $details[] = '<strong>摘取事件详情:</strong>';
+        $details[] = '• 摘取者ID: ' . ($data['picker_id'] ?? '未知');
+        $details[] = '• 摘取数量: ' . ($data['pick_amount'] ?? '未知');
+        $details[] = '• 摘取后剩余数量: ' . ($data['remaining_amount'] ?? '未知');
+        $details[] = '• 摘取比例: ' . (($data['pick_ratio'] ?? 0) * 100) . '%';
+        $details[] = '• 摘取来源: ' . ($data['pick_source'] ?? '未知');
+        $details[] = '• 来源ID: ' . ($data['source_id'] ?? '无');
+        $details[] = '• 摘取物品ID: ' . ($data['item_id'] ?? '未知');
+        $details[] = '• 累计摘取数量: ' . ($data['total_picked'] ?? '未知');
+        $details[] = '• 冷却结束时间: ' . ($data['cooldown_end'] ?? '未知');
+        $details[] = '• 摘取者IP: ' . ($data['ip_address'] ?? '未知');
+
+        return implode('<br>', $details);
+    }
+
     /**
      * 解析铲除作物事件数据(摘要)
      */

+ 12 - 0
app/Module/Farm/Models/FarmSeed.php

@@ -19,6 +19,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
  * @property  int  $item_id  对应的物品ID
  * @property  \App\Module\Farm\Casts\DisasterResistanceCast  $disaster_resistance  灾害抵抗
  * @property  \App\Module\Farm\Casts\DisplayAttributesCast  $display_attributes  显示属性
+ * @property  bool  $pick_enabled  是否允许摘取
+ * @property  float  $pick_max_ratio  最大摘取比例
+ * @property  float  $pick_min_reserve_ratio  最小保留比例
+ * @property  int  $pick_cooldown_seconds  摘取冷却时间(秒)
  * @property  \Carbon\Carbon  $created_at  创建时间
  * @property  \Carbon\Carbon  $updated_at  更新时间
  * field end
@@ -48,6 +52,10 @@ class FarmSeed extends Model
         'item_id',
         'disaster_resistance',
         'display_attributes',
+        'pick_enabled',
+        'pick_max_ratio',
+        'pick_min_reserve_ratio',
+        'pick_cooldown_seconds',
     ];
 
     /**
@@ -59,6 +67,10 @@ class FarmSeed extends Model
         'seed_time' => 'integer',
         'disaster_resistance' => \App\Module\Farm\Casts\DisasterResistanceCast::class,
         'display_attributes' => \App\Module\Farm\Casts\DisplayAttributesCast::class,
+        'pick_enabled' => 'boolean',
+        'pick_max_ratio' => 'decimal:4',
+        'pick_min_reserve_ratio' => 'decimal:4',
+        'pick_cooldown_seconds' => 'integer',
     ];
 
     /**

+ 7 - 0
app/Module/Farm/Providers/FarmServiceProvider.php

@@ -5,12 +5,14 @@ namespace App\Module\Farm\Providers;
 use App\Module\Farm\Commands;
 use App\Module\AppGame\Events\LoginSuccessEvent;
 use App\Module\Farm\Events\CropGrowthStageChangedEvent;
+use App\Module\Farm\Events\CropPickedEvent;
 use App\Module\Farm\Events\FarmCreatedEvent;
 use App\Module\Farm\Events\HouseUpgradedEvent;
 use App\Module\Farm\Listeners\AddLandAfterHouseUpgradeListener;
 use App\Module\Farm\Listeners\FarmInitRewardListener;
 use App\Module\Farm\Listeners\GenerateDisasterListener;
 use App\Module\Farm\Listeners\LoginSuccessListener;
+use App\Module\Farm\Listeners\PickStatisticsListener;
 use App\Module\Farm\Listeners\UpdateCropStatusListener;
 
 use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
@@ -43,6 +45,10 @@ class FarmServiceProvider extends ServiceProvider
         FarmCreatedEvent::class => [
             FarmInitRewardListener::class,
         ],
+
+        CropPickedEvent::class => [
+            PickStatisticsListener::class,
+        ],
     ];
 
     /**
@@ -71,6 +77,7 @@ class FarmServiceProvider extends ServiceProvider
             Commands\GenerateFarmDailyStatsCommand::class,
             Commands\FixLandStatusCommand::class,
             Commands\FixRemovedCropLandStatusCommand::class,
+            Commands\TestPickFunctionCommand::class,
         ]);
 
 

+ 237 - 0
app/Module/Farm/Services/PickService.php

@@ -0,0 +1,237 @@
+<?php
+
+namespace App\Module\Farm\Services;
+
+use App\Module\Farm\Logics\PickLogic;
+use App\Module\Farm\Validations\CropPickValidation;
+use App\Module\Farm\Dtos\PickResultDto;
+use App\Module\Farm\Dtos\PickInfoDto;
+use App\Module\Farm\Models\FarmCrop;
+use App\Module\Farm\Models\FarmCropLog;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 摘取服务类
+ * 提供静态方法供其他模块调用
+ */
+class PickService
+{
+    /**
+     * 摘取指定作物
+     *
+     * @param int $pickerId 摘取者用户ID
+     * @param int $cropId 作物ID(摘取目标)
+     * @param int $pickAmount 摘取数量
+     * @param string $pickSource 摘取来源
+     * @param int|null $sourceId 来源ID(可选)
+     * @return PickResultDto
+     * @throws \Exception
+     */
+    public static function pickCrop(int $pickerId, int $cropId, int $pickAmount, string $pickSource, ?int $sourceId = null): PickResultDto
+    {
+        // 验证摘取参数
+        $validation = new CropPickValidation();
+        $validation->pickerId = $pickerId;
+        $validation->cropId = $cropId;
+        $validation->pickAmount = $pickAmount;
+        $validation->pickSource = $pickSource;
+        $validation->sourceId = $sourceId;
+
+        if (!$validation->validate()) {
+            throw new \Exception($validation->getFirstError());
+        }
+
+        try {
+            return DB::transaction(function () use ($pickerId, $cropId, $pickAmount, $pickSource, $sourceId) {
+                $pickLogic = new PickLogic();
+                $result = $pickLogic->executePick($pickerId, $cropId, $pickAmount, $pickSource, $sourceId);
+
+                Log::info('摘取服务调用成功', [
+                    'picker_id' => $pickerId,
+                    'crop_id' => $cropId,
+                    'pick_amount' => $pickAmount,
+                    'pick_source' => $pickSource,
+                    'source_id' => $sourceId,
+                    'result_log_id' => $result->pickLogId,
+                ]);
+
+                return $result;
+            });
+        } catch (\Exception $e) {
+            Log::error('摘取服务调用失败', [
+                'picker_id' => $pickerId,
+                'crop_id' => $cropId,
+                'pick_amount' => $pickAmount,
+                'pick_source' => $pickSource,
+                'source_id' => $sourceId,
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * 获取作物摘取信息
+     *
+     * @param int $cropId 作物ID
+     * @return PickInfoDto|null
+     */
+    public static function getPickInfo(int $cropId): ?PickInfoDto
+    {
+        try {
+            $pickLogic = new PickLogic();
+            return $pickLogic->getPickInfo($cropId);
+        } catch (\Exception $e) {
+            Log::error('获取摘取信息失败', [
+                'crop_id' => $cropId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return null;
+        }
+    }
+
+    /**
+     * 批量摘取多个作物
+     *
+     * @param int $pickerId 摘取者用户ID
+     * @param array $cropRequests 摘取请求数组
+     * @return array
+     */
+    public static function batchPickCrops(int $pickerId, array $cropRequests): array
+    {
+        try {
+            return DB::transaction(function () use ($pickerId, $cropRequests) {
+                $pickLogic = new PickLogic();
+                $results = $pickLogic->batchPick($pickerId, $cropRequests);
+
+                Log::info('批量摘取服务调用完成', [
+                    'picker_id' => $pickerId,
+                    'total_requests' => count($cropRequests),
+                    'success_count' => count(array_filter($results, fn($r) => $r['success'])),
+                    'failed_count' => count(array_filter($results, fn($r) => !$r['success'])),
+                ]);
+
+                return $results;
+            });
+        } catch (\Exception $e) {
+            Log::error('批量摘取服务调用失败', [
+                'picker_id' => $pickerId,
+                'requests_count' => count($cropRequests),
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * 检查作物是否可以摘取
+     *
+     * @param int $cropId 作物ID
+     * @return array 检查结果和详细原因
+     */
+    public static function canPickCrop(int $cropId): array
+    {
+        try {
+            $crop = FarmCrop::find($cropId);
+            if (!$crop) {
+                return [
+                    'can_pick' => false,
+                    'reason' => '作物不存在',
+                ];
+            }
+
+            $canPick = $crop->canBePicked();
+            $reason = '';
+
+            if (!$canPick) {
+                if ($crop->growth_stage !== \App\Module\Farm\Enums\GROWTH_STAGE::MATURE) {
+                    $reason = '作物未成熟';
+                } elseif ($crop->pickable_amount <= 0) {
+                    $reason = '没有可摘取数量';
+                } elseif ($crop->pick_cooldown_end && now() < $crop->pick_cooldown_end) {
+                    $remainingTime = now()->diffInSeconds($crop->pick_cooldown_end);
+                    $reason = "正在冷却中,还需等待{$remainingTime}秒";
+                } else {
+                    $reason = '不满足摘取条件';
+                }
+            }
+
+            return [
+                'can_pick' => $canPick,
+                'reason' => $reason,
+                'pickable_amount' => $crop->pickable_amount,
+                'total_amount' => $crop->final_output_amount,
+                'picked_amount' => $crop->picked_amount,
+                'next_pick_time' => $crop->pick_cooldown_end,
+            ];
+        } catch (\Exception $e) {
+            Log::error('检查摘取条件失败', [
+                'crop_id' => $cropId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'can_pick' => false,
+                'reason' => '检查失败: ' . $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 获取摘取历史记录
+     *
+     * @param int $userId 用户ID
+     * @param string $type 查询类型:'picker'(摘取者) 或 'owner'(农场主)
+     * @param int $limit 限制数量
+     * @return array
+     */
+    public static function getPickHistory(int $userId, string $type = 'picker', int $limit = 50): array
+    {
+        try {
+            $query = FarmCropLog::where('event_type', FarmCropLog::EVENT_PICKED)
+                ->orderBy('created_at', 'desc')
+                ->limit($limit);
+
+            if ($type === 'picker') {
+                // 查询作为摘取者的记录
+                $query->whereJsonContains('event_data->picker_id', $userId);
+            } else {
+                // 查询作为农场主的记录
+                $query->where('user_id', $userId);
+            }
+
+            $logs = $query->with(['crop', 'seed', 'land'])->get();
+
+            return $logs->map(function ($log) {
+                $eventData = $log->event_data ?? [];
+                return [
+                    'id' => $log->id,
+                    'crop_id' => $log->crop_id,
+                    'picker_id' => $eventData['picker_id'] ?? null,
+                    'owner_id' => $log->user_id,
+                    'pick_amount' => $eventData['pick_amount'] ?? 0,
+                    'item_id' => $eventData['item_id'] ?? null,
+                    'pick_source' => $eventData['pick_source'] ?? 'unknown',
+                    'source_id' => $eventData['source_id'] ?? null,
+                    'pick_time' => $log->created_at,
+                    'crop_info' => $log->crop ? [
+                        'seed_id' => $log->crop->seed_id,
+                        'land_id' => $log->crop->land_id,
+                    ] : null,
+                ];
+            })->toArray();
+        } catch (\Exception $e) {
+            Log::error('获取摘取历史失败', [
+                'user_id' => $userId,
+                'type' => $type,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [];
+        }
+    }
+}

+ 64 - 0
app/Module/Farm/Validations/CropPickValidation.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Module\Farm\Validations;
+
+use UCore\Validation\BaseValidation;
+
+/**
+ * 作物摘取验证类
+ * 
+ * @property int $pickerId 摘取者ID
+ * @property int $cropId 作物ID
+ * @property int $pickAmount 摘取数量
+ * @property string $pickSource 摘取来源
+ * @property int|null $sourceId 来源ID(可选)
+ */
+class CropPickValidation extends BaseValidation
+{
+    public int $pickerId;
+    public int $cropId;
+    public int $pickAmount;
+    public string $pickSource;
+    public ?int $sourceId = null;
+
+    /**
+     * 获取验证规则
+     *
+     * @return array
+     */
+    public function rules(): array
+    {
+        return [
+            'pickerId' => 'required|integer|min:1',
+            'cropId' => 'required|integer|min:1',
+            'pickAmount' => 'required|integer|min:1',
+            'pickSource' => 'required|string|max:50',
+            'sourceId' => 'nullable|integer|min:1',
+        ];
+    }
+
+    /**
+     * 获取验证错误消息
+     *
+     * @return array
+     */
+    public function messages(): array
+    {
+        return [
+            'pickerId.required' => '摘取者ID不能为空',
+            'pickerId.integer' => '摘取者ID必须为整数',
+            'pickerId.min' => '摘取者ID必须大于0',
+            'cropId.required' => '作物ID不能为空',
+            'cropId.integer' => '作物ID必须为整数',
+            'cropId.min' => '作物ID必须大于0',
+            'pickAmount.required' => '摘取数量不能为空',
+            'pickAmount.integer' => '摘取数量必须为整数',
+            'pickAmount.min' => '摘取数量必须大于0',
+            'pickSource.required' => '摘取来源不能为空',
+            'pickSource.string' => '摘取来源必须为字符串',
+            'pickSource.max' => '摘取来源长度不能超过50个字符',
+            'sourceId.integer' => '来源ID必须为整数',
+            'sourceId.min' => '来源ID必须大于0',
+        ];
+    }
+}

+ 47 - 0
app/Module/Farm/Validators/PickAmountValidator.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Module\Farm\Validators;
+
+use App\Module\Farm\Models\FarmCrop;
+use UCore\Validation\BaseValidator;
+
+/**
+ * 摘取数量验证器
+ * 验证摘取数量是否合理
+ */
+class PickAmountValidator extends BaseValidator
+{
+    /**
+     * 验证摘取数量
+     *
+     * @param FarmCrop $crop 作物模型
+     * @param int $pickAmount 摘取数量
+     * @return bool
+     */
+    public function validate(FarmCrop $crop, int $pickAmount): bool
+    {
+        // 检查摘取数量是否超过当前可摘取数量
+        if ($pickAmount > $crop->pickable_amount) {
+            $this->addError("摘取数量({$pickAmount})超过可摘取数量({$crop->pickable_amount})");
+            return false;
+        }
+
+        // 检查单次摘取比例是否超过配置限制
+        $maxRatio = $crop->seed->pick_max_ratio ?? 0.3; // 默认最大30%
+        $pickRatio = $pickAmount / $crop->final_output_amount;
+        
+        if ($pickRatio > $maxRatio) {
+            $maxAmount = (int)($crop->final_output_amount * $maxRatio);
+            $this->addError("单次摘取比例({$pickRatio})超过限制({$maxRatio}),最多可摘取{$maxAmount}个");
+            return false;
+        }
+
+        // 验证摘取数量的合理性(必须大于0)
+        if ($pickAmount <= 0) {
+            $this->addError('摘取数量必须大于0');
+            return false;
+        }
+
+        return true;
+    }
+}

+ 32 - 0
app/Module/Farm/Validators/PickCooldownValidator.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Module\Farm\Validators;
+
+use App\Module\Farm\Models\FarmCrop;
+use UCore\Validation\BaseValidator;
+use Carbon\Carbon;
+
+/**
+ * 摘取冷却验证器
+ * 验证摘取冷却时间
+ */
+class PickCooldownValidator extends BaseValidator
+{
+    /**
+     * 验证摘取冷却时间
+     *
+     * @param FarmCrop $crop 作物模型
+     * @return bool
+     */
+    public function validate(FarmCrop $crop): bool
+    {
+        // 检查作物是否在摘取冷却期内
+        if ($crop->pick_cooldown_end && now() < $crop->pick_cooldown_end) {
+            $remainingTime = now()->diffInSeconds($crop->pick_cooldown_end);
+            $this->addError("作物正在冷却中,还需等待{$remainingTime}秒");
+            return false;
+        }
+
+        return true;
+    }
+}

+ 68 - 0
app/Module/Farm/Validators/PickSourceValidator.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Module\Farm\Validators;
+
+use UCore\Validation\BaseValidator;
+
+/**
+ * 摘取来源验证器
+ * 验证摘取来源信息
+ */
+class PickSourceValidator extends BaseValidator
+{
+    // 允许的摘取来源类型
+    private const ALLOWED_SOURCES = [
+        'manual',        // 手动摘取
+        'friend_visit',  // 好友访问摘取
+        'system_auto',   // 系统自动摘取
+        'task_reward',   // 任务奖励摘取
+        'event_bonus',   // 活动奖励摘取
+    ];
+
+    /**
+     * 验证摘取来源
+     *
+     * @param string $pickSource 摘取来源
+     * @param int|null $sourceId 来源ID
+     * @return bool
+     */
+    public function validate(string $pickSource, ?int $sourceId = null): bool
+    {
+        // 验证摘取来源字符串格式
+        if (empty($pickSource)) {
+            $this->addError('摘取来源不能为空');
+            return false;
+        }
+
+        if (!in_array($pickSource, self::ALLOWED_SOURCES)) {
+            $allowedSources = implode(', ', self::ALLOWED_SOURCES);
+            $this->addError("无效的摘取来源: {$pickSource},允许的来源: {$allowedSources}");
+            return false;
+        }
+
+        // 检查来源ID的有效性(如果提供)
+        if ($sourceId !== null && $sourceId <= 0) {
+            $this->addError('来源ID必须大于0');
+            return false;
+        }
+
+        // 某些来源类型需要提供来源ID
+        $requireSourceId = ['friend_visit', 'task_reward', 'event_bonus'];
+        if (in_array($pickSource, $requireSourceId) && $sourceId === null) {
+            $this->addError("摘取来源 {$pickSource} 需要提供来源ID");
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 获取允许的摘取来源列表
+     *
+     * @return array
+     */
+    public static function getAllowedSources(): array
+    {
+        return self::ALLOWED_SOURCES;
+    }
+}

+ 62 - 0
app/Module/Farm/Validators/PickableStatusValidator.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Module\Farm\Validators;
+
+use App\Module\Farm\Models\FarmCrop;
+use App\Module\Farm\Enums\GROWTH_STAGE;
+use UCore\Validation\BaseValidator;
+
+/**
+ * 摘取状态验证器
+ * 验证作物是否满足摘取条件
+ */
+class PickableStatusValidator extends BaseValidator
+{
+    private ?FarmCrop $crop = null;
+
+    /**
+     * 验证作物摘取状态
+     *
+     * @param int $cropId 作物ID
+     * @return bool
+     */
+    public function validate(int $cropId): bool
+    {
+        // 验证作物是否存在
+        $this->crop = FarmCrop::find($cropId);
+        if (!$this->crop) {
+            $this->addError('作物不存在');
+            return false;
+        }
+
+        // 检查作物是否处于成熟期
+        if ($this->crop->growth_stage !== GROWTH_STAGE::MATURE) {
+            $this->addError('作物未成熟,无法摘取');
+            return false;
+        }
+
+        // 检查是否有最终产出
+        if ($this->crop->final_output_amount <= 0) {
+            $this->addError('作物没有产出,无法摘取');
+            return false;
+        }
+
+        // 检查是否有可摘取数量
+        if ($this->crop->pickable_amount <= 0) {
+            $this->addError('作物没有可摘取数量');
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 获取验证通过的作物信息
+     *
+     * @return FarmCrop|null
+     */
+    public function getCrop(): ?FarmCrop
+    {
+        return $this->crop;
+    }
+}

+ 100 - 0
tests/Unit/Farm/PickServiceTest.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace Tests\Unit\Farm;
+
+use Tests\TestCase;
+use App\Module\Farm\Services\PickService;
+use App\Module\Farm\Models\FarmCrop;
+use App\Module\Farm\Models\FarmSeed;
+use App\Module\Farm\Models\FarmLand;
+use App\Module\Farm\Models\FarmUser;
+use App\Module\Farm\Enums\GROWTH_STAGE;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+/**
+ * 摘取服务测试
+ */
+class PickServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    /**
+     * 测试获取摘取信息
+     */
+    public function test_get_pick_info()
+    {
+        // 创建测试数据
+        $seed = FarmSeed::factory()->create([
+            'pick_enabled' => true,
+            'pick_max_ratio' => 0.3,
+            'pick_min_reserve_ratio' => 0.1,
+            'pick_cooldown_seconds' => 1800,
+        ]);
+
+        $land = FarmLand::factory()->create();
+        $farmUser = FarmUser::factory()->create();
+
+        $crop = FarmCrop::factory()->create([
+            'seed_id' => $seed->id,
+            'land_id' => $land->id,
+            'user_id' => $farmUser->user_id,
+            'growth_stage' => GROWTH_STAGE::MATURE,
+            'final_output_amount' => 100,
+            'picked_amount' => 0,
+            'min_reserve_amount' => 10,
+        ]);
+
+        // 测试获取摘取信息
+        $pickInfo = PickService::getPickInfo($crop->id);
+
+        $this->assertNotNull($pickInfo);
+        $this->assertEquals($crop->id, $pickInfo->cropId);
+        $this->assertEquals(90, $pickInfo->pickableAmount); // 100 - 0 - 10
+        $this->assertTrue($pickInfo->canPick);
+    }
+
+    /**
+     * 测试检查摘取条件
+     */
+    public function test_can_pick_crop()
+    {
+        // 创建成熟的作物
+        $seed = FarmSeed::factory()->create(['pick_enabled' => true]);
+        $land = FarmLand::factory()->create();
+        $farmUser = FarmUser::factory()->create();
+
+        $crop = FarmCrop::factory()->create([
+            'seed_id' => $seed->id,
+            'land_id' => $land->id,
+            'user_id' => $farmUser->user_id,
+            'growth_stage' => GROWTH_STAGE::MATURE,
+            'final_output_amount' => 100,
+            'picked_amount' => 0,
+            'min_reserve_amount' => 10,
+        ]);
+
+        // 测试可以摘取
+        $result = PickService::canPickCrop($crop->id);
+        $this->assertTrue($result['can_pick']);
+
+        // 测试不成熟的作物
+        $crop->growth_stage = GROWTH_STAGE::GROWTH;
+        $crop->save();
+
+        $result = PickService::canPickCrop($crop->id);
+        $this->assertFalse($result['can_pick']);
+        $this->assertStringContains('未成熟', $result['reason']);
+    }
+
+    /**
+     * 测试摘取功能(需要模拟ItemService)
+     */
+    public function test_pick_crop_basic_validation()
+    {
+        // 测试基本验证
+        $this->expectException(\Exception::class);
+        
+        // 尝试摘取不存在的作物
+        PickService::pickCrop(1, 999999, 10, 'manual');
+    }
+}