From a30565b3a06288122ecb3d0f25c7466e422d878c Mon Sep 17 00:00:00 2001 From: sugar1569 Date: Thu, 24 Sep 2026 11:58:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EMCP=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=9B=86=E5=90=88=EF=BC=8C=E5=8C=85=E5=90=AB=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E3=80=81=E8=B4=A2=E5=8A=A1=E7=B1=BB=E4=B8=9A=E5=8A=A1=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交新增12个MCP工具,覆盖用户管理与财务统计场景: - 用户类:用户标签列表、标签创建/删除/设置、分组设置、会员等级/时长赠送、用户资料/备注修改 - 财务类:充值记录/统计、余额流水、佣金流水、提现申请/统计、发票记录 所有工具均实现完整的入参校验、业务逻辑与脱敏处理,符合管理后台同链路操作逻辑。 --- .../service/mcp/tools/CouponCreateTool.php | 412 ++++++++++++++++++ .../service/mcp/tools/ExtractListTool.php | 167 +++++++ .../mcp/tools/ExtractStatisticsTool.php | 129 ++++++ .../service/mcp/tools/FinanceOverviewTool.php | 207 +++++++++ .../service/mcp/tools/InvoiceListTool.php | 136 ++++++ .../service/mcp/tools/RechargeListTool.php | 128 ++++++ .../mcp/tools/RechargeStatisticsTool.php | 129 ++++++ .../mcp/tools/UserBrokerageListTool.php | 135 ++++++ .../service/mcp/tools/UserEditPayload.php | 170 ++++++++ .../service/mcp/tools/UserGroupSetTool.php | 141 ++++++ .../service/mcp/tools/UserLabelCreateTool.php | 182 ++++++++ .../service/mcp/tools/UserLabelDeleteTool.php | 157 +++++++ .../service/mcp/tools/UserLabelListTool.php | 125 ++++++ .../service/mcp/tools/UserLabelSetTool.php | 165 +++++++ .../service/mcp/tools/UserLabelUpdateTool.php | 199 +++++++++ .../service/mcp/tools/UserLevelGiveTool.php | 141 ++++++ .../service/mcp/tools/UserMarkSetTool.php | 150 +++++++ .../mcp/tools/UserMemberTimeGiveTool.php | 173 ++++++++ .../service/mcp/tools/UserMoneyListTool.php | 131 ++++++ .../service/mcp/tools/UserUpdateTool.php | 171 ++++++++ 20 files changed, 3348 insertions(+) create mode 100644 backend/modules/service/mcp/tools/CouponCreateTool.php create mode 100644 backend/modules/service/mcp/tools/ExtractListTool.php create mode 100644 backend/modules/service/mcp/tools/ExtractStatisticsTool.php create mode 100644 backend/modules/service/mcp/tools/FinanceOverviewTool.php create mode 100644 backend/modules/service/mcp/tools/InvoiceListTool.php create mode 100644 backend/modules/service/mcp/tools/RechargeListTool.php create mode 100644 backend/modules/service/mcp/tools/RechargeStatisticsTool.php create mode 100644 backend/modules/service/mcp/tools/UserBrokerageListTool.php create mode 100644 backend/modules/service/mcp/tools/UserEditPayload.php create mode 100644 backend/modules/service/mcp/tools/UserGroupSetTool.php create mode 100644 backend/modules/service/mcp/tools/UserLabelCreateTool.php create mode 100644 backend/modules/service/mcp/tools/UserLabelDeleteTool.php create mode 100644 backend/modules/service/mcp/tools/UserLabelListTool.php create mode 100644 backend/modules/service/mcp/tools/UserLabelSetTool.php create mode 100644 backend/modules/service/mcp/tools/UserLabelUpdateTool.php create mode 100644 backend/modules/service/mcp/tools/UserLevelGiveTool.php create mode 100644 backend/modules/service/mcp/tools/UserMarkSetTool.php create mode 100644 backend/modules/service/mcp/tools/UserMemberTimeGiveTool.php create mode 100644 backend/modules/service/mcp/tools/UserMoneyListTool.php create mode 100644 backend/modules/service/mcp/tools/UserUpdateTool.php diff --git a/backend/modules/service/mcp/tools/CouponCreateTool.php b/backend/modules/service/mcp/tools/CouponCreateTool.php new file mode 100644 index 000000000..1482ff427 --- /dev/null +++ b/backend/modules/service/mcp/tools/CouponCreateTool.php @@ -0,0 +1,412 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\product\CenterProductDao; +use app\dao\product\product\StoreCategoryDao; +use app\services\activity\coupon\StoreCouponIssueServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:添加优惠券(创建发行配置)。 + * + * 与管理后台「添加优惠券」同链路:组装与 StoreCouponIssueController::saveCoupon + * 一致的数据结构后调用 StoreCouponIssueServices::saveCoupon;参数预检在工具层 + * 完成(McpException 消息可透传),服务层业务校验不因 AI 调用而豁免。 + */ +class CouponCreateTool implements McpToolInterface +{ + /** + * 优惠券发行服务 + * @var StoreCouponIssueServices + */ + private StoreCouponIssueServices $services; + + /** + * 商品分类 DAO(品类券 ID 存在性校验) + * @var StoreCategoryDao + */ + private StoreCategoryDao $categoryDao; + + /** + * 中台商品 DAO(商品券 ID 存在性校验) + * @var CenterProductDao + */ + private CenterProductDao $productDao; + + /** + * 构造函数 + * @param StoreCouponIssueServices $services 优惠券发行服务 + * @param StoreCategoryDao $categoryDao 商品分类 DAO + * @param CenterProductDao $productDao 中台商品 DAO + */ + public function __construct( + StoreCouponIssueServices $services, + StoreCategoryDao $categoryDao, + CenterProductDao $productDao + ) { + $this->services = $services; + $this->categoryDao = $categoryDao; + $this->productDao = $productDao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_coupon_create'; + } + + /** + * 交互声明:创建优惠券会新增营销配置并可能被用户领取,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '创建一张新的优惠券发行配置,等同管理后台「添加优惠券」,成功返回新建的 coupon_id;' + . '不能编辑或删除已有优惠券,创建后的停用、开启、删除需在管理后台操作。' + . '必填参数:coupon_title 优惠券名称(18 字以内);coupon_price 面值(元,必须大于 0);' + . 'use_min_price 使用门槛(元,0=无门槛,默认 0)。' + . 'type 优惠券类型:0=通用券(默认)、1=品类券(必须传 category_id 品类 ID 数组)、2=商品券(必须传 product_id 商品 ID 数组);' + . '品类/商品 ID 必须真实存在(工具会校验),创建前先通过 crmeb_category_list 查品类、crmeb_product_list 查商品获取有效 ID。' + . '有效期二选一:coupon_time 领取后有效天数(正整数,默认 0),coupon_time=0 时必须传 start_use_time 与 end_use_time 时间段' + . '(格式 YYYY-MM-DD HH:mm:ss,开始时间不能早于今天且不能早于领取开始时间,结束时间不能早于开始时间;coupon_time 大于 0 时忽略时间段参数)。' + . 'user_type 适用用户:1=普通用户(默认)、2=付费会员专享(此时 receive_type 参数被忽略,服务端固定为会员领取)。' + . 'receive_type 发送方式:1=用户主动领取(默认)、3=系统赠送(固定不限量,忽略发布数量设置)。' + . '发布数量:is_permanent=1 不限量(默认);is_permanent=0 限量时 total_count 发布张数(≥1)必须 ≥ receive_limit 每人限领张数(默认 1)。' + . '领取时间可选:start_time 与 end_time 必须成对提供(YYYY-MM-DD HH:mm:ss,开始时间不能早于今天,结束时间不能早于开始时间),不传表示不限时;' + . '使用有效期开始时间不能早于领取开始时间,若两者同日请将使用开始时间设置为不早于领取开始时刻。' + . 'sort 排序值(默认 0);status:1=开启(默认)、0=关闭。' + . '创建后可通过 crmeb_coupon_list 查询配置,用户领取记录见 crmeb_coupon_claim_list。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '优惠券创建'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '营销'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['coupon_title', 'coupon_price'], + 'properties' => [ + 'coupon_title' => ['type' => 'string', 'maxLength' => 18, 'description' => '优惠券名称,18 字以内'], + 'coupon_price' => ['type' => 'number', 'exclusiveMinimum' => 0, 'description' => '优惠券面值(元),必须大于 0'], + 'use_min_price' => ['type' => 'number', 'minimum' => 0, 'description' => '使用门槛(最低消费金额,元),0=无门槛,默认 0'], + 'type' => ['type' => 'integer', 'enum' => [0, 1, 2], 'description' => '0=通用券(默认),1=品类券,2=商品券'], + 'category_id' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => '品类券适用的品类 ID 数组(任意层级均可),type=1 时必填,ID 需真实存在,可先通过 crmeb_category_list 查询'], + 'product_id' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => '商品券适用的中台商品 ID 数组,type=2 时必填,ID 需真实存在,可先通过 crmeb_product_list 查询'], + 'coupon_time' => ['type' => 'integer', 'minimum' => 0, 'description' => '领取后有效天数;0=改用 start_use_time/end_use_time 时间段(默认 0),大于 0 时忽略时间段'], + 'start_use_time' => ['type' => 'string', 'description' => '使用有效期开始时间,YYYY-MM-DD HH:mm:ss,coupon_time=0 时必填,不能早于今天及领取开始时间'], + 'end_use_time' => ['type' => 'string', 'description' => '使用有效期结束时间,YYYY-MM-DD HH:mm:ss,coupon_time=0 时必填,不能早于开始时间'], + 'user_type' => ['type' => 'integer', 'enum' => [1, 2], 'description' => '1=普通用户可领(默认),2=付费会员专享'], + 'receive_type' => ['type' => 'integer', 'enum' => [1, 3], 'description' => '1=用户主动领取(默认),3=系统赠送;user_type=2 时被忽略'], + 'is_permanent' => ['type' => 'integer', 'enum' => [0, 1], 'description' => '1=不限量(默认),0=限量发布'], + 'total_count' => ['type' => 'integer', 'minimum' => 1, 'description' => '发布数量(张),is_permanent=0 时必须 ≥ receive_limit,默认 1'], + 'receive_limit' => ['type' => 'integer', 'minimum' => 1, 'description' => '每人限领数量(张),默认 1'], + 'start_time' => ['type' => 'string', 'description' => '领取开始时间,YYYY-MM-DD HH:mm:ss,与 end_time 成对提供,不能早于今天'], + 'end_time' => ['type' => 'string', 'description' => '领取结束时间,YYYY-MM-DD HH:mm:ss,与 start_time 成对提供,不能早于领取开始时间'], + 'sort' => ['type' => 'integer', 'description' => '排序值,默认 0'], + 'status' => ['type' => 'integer', 'enum' => [0, 1], 'description' => '1=开启(默认),0=关闭'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 优惠券配置参数 + * @return array 新建优惠券 ID 与生效配置摘要 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $title = trim((string)($args['coupon_title'] ?? '')); + if ($title === '') { + throw new McpException('coupon_title 不能为空'); + } + if (mb_strlen($title) > 18) { + throw new McpException('coupon_title 不能超过 18 个字'); + } + $couponPrice = self::numberArg($args, 'coupon_price', 0.0); + if ($couponPrice <= 0) { + throw new McpException('coupon_price 必须大于 0'); + } + $useMinPrice = self::numberArg($args, 'use_min_price', 0.0); + if ($useMinPrice < 0) { + throw new McpException('use_min_price 不能小于 0'); + } + + $type = self::integerArg($args, 'type', 0, 0, 2); + $categoryId = []; + $productId = []; + if ($type === 1) { + $categoryId = self::idArrayArg($args, 'category_id'); + if (!$categoryId) { + throw new McpException('品类券(type=1)必须提供 category_id 品类 ID 数组'); + } + $this->validateCategoryIds($categoryId); + } elseif ($type === 2) { + $productId = self::idArrayArg($args, 'product_id'); + if (!$productId) { + throw new McpException('商品券(type=2)必须提供 product_id 商品 ID 数组'); + } + $this->validateProductIds($productId); + } + + // 领取时间可选,成对提供;user_type=2 时服务层强制会员领取,这里对齐后台传值 + $userType = self::integerArg($args, 'user_type', 1, 1, 2); + $receiveType = $userType === 2 ? 1 : self::integerArg($args, 'receive_type', 1, 1, 3); + $startTime = self::dateTimeArg($args, 'start_time'); + $endTime = self::dateTimeArg($args, 'end_time'); + if (($startTime === '') !== ($endTime === '')) { + throw new McpException('start_time 与 end_time 必须同时提供'); + } + if ($startTime !== '') { + if (strtotime($startTime) < strtotime(date('Y-m-d 00:00:00'))) { + throw new McpException('start_time 领取开始时间不能早于当前时间'); + } + if (strtotime($endTime) < strtotime($startTime)) { + throw new McpException('end_time 领取结束时间不能早于领取开始时间'); + } + } + + // 使用有效期:整天数模式忽略时间段;时间段模式校验成对、不早于今天与先后顺序 + $couponTime = self::integerArg($args, 'coupon_time', 0, 0); + $startUseTime = $endUseTime = ''; + if ($couponTime === 0) { + $startUseTime = self::dateTimeArg($args, 'start_use_time'); + $endUseTime = self::dateTimeArg($args, 'end_use_time'); + if ($startUseTime === '' || $endUseTime === '') { + throw new McpException('coupon_time 为 0 时必须提供 start_use_time 与 end_use_time(YYYY-MM-DD HH:mm:ss)'); + } + if (strtotime($startUseTime) < strtotime(date('Y-m-d 00:00:00'))) { + throw new McpException('start_use_time 开始使用时间不能早于当前时间'); + } + if (strtotime($endUseTime) < strtotime($startUseTime)) { + throw new McpException('end_use_time 结束使用时间不能早于开始使用时间'); + } + // 与服务层规则一致:使用开始不能早于领取开始 + if ($startTime !== '' && strtotime($startUseTime) < strtotime($startTime)) { + throw new McpException('start_use_time 使用开始时间不能早于 start_time 领取开始时间'); + } + } + + // 数量规则:系统赠送固定不限量;限量时每人限领不得大于发布数量 + $receiveLimit = self::integerArg($args, 'receive_limit', 1, 1); + $isPermanent = self::integerArg($args, 'is_permanent', 1, 0, 1); + if ($receiveType === 3) { + $isPermanent = 1; + } + $totalCount = self::integerArg($args, 'total_count', 1, 1); + if ($isPermanent === 1) { + $totalCount = 0; + } elseif ($receiveLimit > $totalCount) { + throw new McpException('receive_limit 每人限领数量不能大于 total_count 发布数量'); + } + + $status = self::integerArg($args, 'status', 1, 0, 1); + $sort = self::integerArg($args, 'sort', 0); + + // 与 StoreCouponIssueController::saveCoupon 的 postMore 结构保持一致,由服务层完成入库 + $data = [ + 'id' => 0, + 'coupon_title' => $title, + 'coupon_price' => $couponPrice, + 'use_min_price' => $useMinPrice, + 'coupon_time' => $couponTime, + 'start_use_time' => $startUseTime === '' ? 0 : $startUseTime, + 'end_use_time' => $endUseTime === '' ? 0 : $endUseTime, + 'start_time' => $startTime === '' ? 0 : $startTime, + 'end_time' => $endTime === '' ? 0 : $endTime, + 'receive_type' => $receiveType, + 'is_permanent' => $isPermanent, + 'total_count' => $totalCount, + 'product_id' => $productId ? implode(',', $productId) : '', + 'category_id' => $categoryId, + 'type' => $type, + 'sort' => $sort, + 'status' => $status, + 'receive_limit' => $receiveLimit, + 'user_type' => $userType, + ]; + + try { + $couponId = $this->services->saveCoupon($data); + } catch (AdminException $e) { + // 服务层业务校验失败:转 McpException 让消息透传给 AI 客户端 + throw new McpException($e->getMessage()); + } + + return [ + 'coupon_id' => (int)$couponId, + 'coupon_title' => $title, + 'coupon_price' => $couponPrice, + 'use_min_price' => $useMinPrice, + 'type' => $type, + 'receive_type' => $userType === 2 ? 4 : $receiveType, + 'is_permanent' => $isPermanent, + 'total_count' => $totalCount, + 'receive_limit' => $receiveLimit, + 'status' => $status, + 'message' => '优惠券创建成功,可通过 crmeb_coupon_list 查询', + ]; + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @param float $default 默认值 + * @return float 数值 + * @throws McpException 非数字 + */ + private static function numberArg(array $args, string $name, float $default): float + { + $value = $args[$name] ?? $default; + if (is_int($value) || is_float($value)) { + return (float)$value; + } + if (is_string($value) && $value !== '' && is_numeric($value)) { + return (float)$value; + } + throw new McpException($name . ' 必须是数字'); + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @param int $default 默认值 + * @param int|null $min 最小值 + * @param int|null $max 最大值 + * @return int 整数 + * @throws McpException 非整数或超出范围 + */ + private static function integerArg(array $args, string $name, int $default, ?int $min = null, ?int $max = null): int + { + $value = $args[$name] ?? $default; + if (!is_int($value) && (!is_string($value) || !ctype_digit($value))) { + throw new McpException($name . ' 必须是整数'); + } + $options = array_filter(['min_range' => $min, 'max_range' => $max], static fn($v) => $v !== null); + $number = filter_var($value, FILTER_VALIDATE_INT, ['options' => $options]); + if ($number === false) { + throw new McpException($name . ' 取值超出允许范围'); + } + return $number; + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @return int[] 去重后的正整数 ID 列表 + * @throws McpException 不是 ID 数组 + */ + private static function idArrayArg(array $args, string $name): array + { + $value = $args[$name] ?? []; + if (is_int($value) || (is_string($value) && $value !== '')) { + $value = [$value]; + } + if (!is_array($value)) { + throw new McpException($name . ' 必须是 ID 数组'); + } + $ids = []; + foreach ($value as $item) { + $valid = is_int($item) || (is_string($item) && ctype_digit($item)); + if (!$valid || (int)$item <= 0) { + throw new McpException($name . ' 数组内必须全部是大于 0 的整数 ID'); + } + $ids[] = (int)$item; + } + return array_values(array_unique($ids)); + } + + /** + * 校验品类 ID 均真实存在(服务层不校验,防止创建出永远匹配不到商品的无效品类券) + * @param int[] $categoryIds 品类 ID 列表 + * @return void + * @throws McpException 存在不存在的品类 ID + */ + private function validateCategoryIds(array $categoryIds): void + { + $exist = (array)$this->categoryDao->getColumn([['id', 'in', $categoryIds]], 'id'); + $missing = array_diff($categoryIds, array_map('intval', $exist)); + if ($missing) { + throw new McpException('category_id 中以下品类 ID 不存在:' . implode('、', $missing) . ',请先通过 crmeb_category_list 查询有效品类 ID'); + } + } + + /** + * 校验商品 ID 均真实存在(服务层不校验,防止创建出无法关联商品的无效商品券) + * @param int[] $productIds 商品 ID 列表 + * @return void + * @throws McpException 存在不存在的商品 ID + */ + private function validateProductIds(array $productIds): void + { + $exist = (array)$this->productDao->getColumn([['id', 'in', $productIds]], 'id'); + $missing = array_diff($productIds, array_map('intval', $exist)); + if ($missing) { + throw new McpException('product_id 中以下商品 ID 不存在:' . implode('、', $missing) . ',请先通过 crmeb_product_list 查询有效商品 ID'); + } + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @return string 可解析的时间字符串,未传返回空串 + * @throws McpException 时间格式无效 + */ + private static function dateTimeArg(array $args, string $name): string + { + $value = $args[$name] ?? ''; + if ($value === '' || $value === 0 || $value === '0') { + return ''; + } + if ((!is_string($value) && !is_int($value)) || strtotime((string)$value) === false) { + throw new McpException($name . ' 必须是有效的时间字符串,格式 YYYY-MM-DD HH:mm:ss'); + } + return (string)$value; + } +} diff --git a/backend/modules/service/mcp/tools/ExtractListTool.php b/backend/modules/service/mcp/tools/ExtractListTool.php new file mode 100644 index 000000000..df8f7d27b --- /dev/null +++ b/backend/modules/service/mcp/tools/ExtractListTool.php @@ -0,0 +1,167 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserExtractDao; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\SensitiveData; + +/** + * MCP 工具:提现申请列表 + * + * 银行账号、支付宝账号、姓名、微信号等敏感信息脱敏后返回。 + */ +class ExtractListTool implements McpToolInterface +{ + /** + * 提现 DAO + * @var UserExtractDao + */ + private UserExtractDao $dao; + + /** + * 构造函数 + * @param UserExtractDao $dao 提现 DAO + */ + public function __construct(UserExtractDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_extract_list'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '查询用户佣金提现申请列表,支持按状态、提现方式、用户 UID 筛选;' + . 'status:0 审核中、1 已提现、-1 未通过(未通过原因见 fail_msg);' + . 'extract_type:bank 银行卡、alipay 支付宝、weixin 微信、balance 转余额;' + . 'balance 字段为提现前佣金余额。银行账号、支付宝账号、姓名、微信号已脱敏。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '提现申请列表'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'page' => ['type' => 'number', 'description' => '页码'], + 'limit' => ['type' => 'number', 'description' => '每页数量'], + 'uid' => ['type' => 'number', 'description' => '用户 UID'], + 'status' => ['type' => 'number', 'description' => '状态:0 审核中、1 已提现、-1 未通过'], + 'extract_type' => ['type' => 'string', 'description' => '提现方式:bank/alipay/weixin/balance'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(page/limit/uid/status/extract_type) + * @return array 提现申请列表和总数 + */ + public function execute(array $args): array + { + $page = max(1, (int)($args['page'] ?? 1)); + $limit = min(100, max(1, (int)($args['limit'] ?? 10))); + + $where = []; + if (!empty($args['uid'])) { + $where[] = ['uid', '=', (int)$args['uid']]; + } + if (isset($args['status']) && $args['status'] !== '') { + $where[] = ['status', '=', (int)$args['status']]; + } + if (isset($args['extract_type']) && $args['extract_type'] !== '') { + $where[] = ['extract_type', '=', (string)$args['extract_type']]; + } + + $list = $this->dao->search($where) + ->field('id,uid,real_name,extract_type,bank_code,alipay_code,wechat,extract_price,extract_fee,balance,mark,status,fail_msg,add_time') + ->order('id desc') + ->page($page, $limit) + ->select() + ->toArray(); + + foreach ($list as &$item) { + $item['real_name'] = self::maskName((string)($item['real_name'] ?? '')); + $item['bank_code'] = self::maskAccount((string)($item['bank_code'] ?? '')); + $item['alipay_code'] = self::maskAccount((string)($item['alipay_code'] ?? '')); + $item['wechat'] = self::maskAccount((string)($item['wechat'] ?? '')); + } + unset($item); + + $count = $this->dao->search($where)->count(); + return ['list' => $list, 'count' => $count]; + } + + /** + * 姓名脱敏(仅保留首字符) + * @param string $value 姓名 + * @return string + */ + private static function maskName(string $value): string + { + if ($value === '') { + return ''; + } + return mb_substr($value, 0, 1) . str_repeat('*', max(0, mb_strlen($value) - 1)); + } + + /** + * 账号类信息脱敏(保留后 4 位) + * @param string $value 账号 + * @return string + */ + private static function maskAccount(string $value): string + { + if ($value === '') { + return ''; + } + if (mb_strlen($value) <= 4) { + return '******'; + } + return str_repeat('*', mb_strlen($value) - 4) . mb_substr($value, -4); + } +} diff --git a/backend/modules/service/mcp/tools/ExtractStatisticsTool.php b/backend/modules/service/mcp/tools/ExtractStatisticsTool.php new file mode 100644 index 000000000..2cf9f8096 --- /dev/null +++ b/backend/modules/service/mcp/tools/ExtractStatisticsTool.php @@ -0,0 +1,129 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserExtractDao; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\ReportPeriod; + +/** + * MCP 工具:提现统计 + * + * 按提现申请时间统计各状态笔数与金额。 + */ +class ExtractStatisticsTool implements McpToolInterface +{ + /** + * 提现 DAO + * @var UserExtractDao + */ + private UserExtractDao $dao; + + /** + * 构造函数 + * @param UserExtractDao $dao 提现 DAO + */ + public function __construct(UserExtractDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_extract_statistics'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '按提现申请时间(add_time)统计指定时间段内的提现情况:' + . '已提现(成功)笔数、金额与手续费合计,审核中笔数与金额,未通过笔数。' + . '不按审核/打款时间统计,金额为申请金额。默认近7天,最多31天。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '提现统计'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'start_date' => ['type' => 'string', 'format' => 'date', 'description' => '开始日期 YYYY-MM-DD'], + 'end_date' => ['type' => 'string', 'format' => 'date', 'description' => '结束日期 YYYY-MM-DD,包含结束日'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(start_date/end_date) + * @return array 提现统计结果 + */ + public function execute(array $args): array + { + $period = ReportPeriod::fromArgs($args, '提现统计'); + $range = [$period['start_time'], $period['end_time']]; + + $sumFields = ['COUNT(id) AS cnt', 'COALESCE(SUM(extract_price),0) AS amount', 'COALESCE(SUM(extract_fee),0) AS fee']; + $success = $this->dao->search([['status', '=', 1]]) + ->whereBetween('add_time', $range)->field($sumFields)->find(); + $success = $success ? $success->toArray() : []; + $auditing = $this->dao->search([['status', '=', 0]]) + ->whereBetween('add_time', $range)->field($sumFields)->find(); + $auditing = $auditing ? $auditing->toArray() : []; + $fail = $this->dao->search([['status', '=', -1]]) + ->whereBetween('add_time', $range)->field(['COUNT(id) AS cnt'])->find(); + $fail = $fail ? $fail->toArray() : []; + + return [ + 'start_date' => $period['start_date'], + 'end_date' => $period['end_date'], + 'metrics' => [ + 'success_count' => (int)($success['cnt'] ?? 0), + 'success_amount' => (string)($success['amount'] ?? '0.00'), + 'success_fee' => (string)($success['fee'] ?? '0.00'), + 'auditing_count' => (int)($auditing['cnt'] ?? 0), + 'auditing_amount' => (string)($auditing['amount'] ?? '0.00'), + 'fail_count' => (int)($fail['cnt'] ?? 0), + ], + ]; + } +} diff --git a/backend/modules/service/mcp/tools/FinanceOverviewTool.php b/backend/modules/service/mcp/tools/FinanceOverviewTool.php new file mode 100644 index 000000000..fbb31bcae --- /dev/null +++ b/backend/modules/service/mcp/tools/FinanceOverviewTool.php @@ -0,0 +1,207 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserBrokerageDao; +use app\dao\user\UserExtractDao; +use app\dao\user\UserMoneyDao; +use app\dao\user\UserRechargeDao; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\ReportPeriod; + +/** + * MCP 工具:财务总览统计 + * + * 汇总指定时间段内平台核心资金数据:充值、提现、余额流水、佣金流水。 + */ +class FinanceOverviewTool implements McpToolInterface +{ + /** + * 充值 DAO + * @var UserRechargeDao + */ + private UserRechargeDao $rechargeDao; + + /** + * 提现 DAO + * @var UserExtractDao + */ + private UserExtractDao $extractDao; + + /** + * 余额流水 DAO + * @var UserMoneyDao + */ + private UserMoneyDao $moneyDao; + + /** + * 佣金流水 DAO + * @var UserBrokerageDao + */ + private UserBrokerageDao $brokerageDao; + + /** + * 构造函数 + * @param UserRechargeDao $rechargeDao 充值 DAO + * @param UserExtractDao $extractDao 提现 DAO + * @param UserMoneyDao $moneyDao 余额流水 DAO + * @param UserBrokerageDao $brokerageDao 佣金流水 DAO + */ + public function __construct( + UserRechargeDao $rechargeDao, + UserExtractDao $extractDao, + UserMoneyDao $moneyDao, + UserBrokerageDao $brokerageDao + ) { + $this->rechargeDao = $rechargeDao; + $this->extractDao = $extractDao; + $this->moneyDao = $moneyDao; + $this->brokerageDao = $brokerageDao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_finance_overview'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '汇总统计指定时间段内平台核心资金数据:' + . '充值(仅已支付,按支付时间统计笔数、金额、赠送金额);' + . '提现(按申请时间统计已提现/审核中/未通过的笔数与金额及成功手续费);' + . '余额流水(按记账时间统计有效记录的收入与支出笔数、金额);' + . '佣金流水(按记账时间统计有效记录的收入与支出笔数、金额,收入含冻结中佣金)。' + . '不包含积分/经验变动,金额单位为元。默认近7天,最多31天。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '财务总览'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'start_date' => ['type' => 'string', 'format' => 'date', 'description' => '开始日期 YYYY-MM-DD'], + 'end_date' => ['type' => 'string', 'format' => 'date', 'description' => '结束日期 YYYY-MM-DD,包含结束日'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(start_date/end_date) + * @return array 财务总览统计结果 + */ + public function execute(array $args): array + { + $period = ReportPeriod::fromArgs($args, '财务总览'); + $range = [$period['start_time'], $period['end_time']]; + + // 充值:仅已支付,按支付时间 + $recharge = $this->rechargeDao->search([['paid', '=', 1]]) + ->whereBetween('pay_time', $range) + ->field(['COUNT(id) AS cnt', 'COALESCE(SUM(price),0) AS amount', 'COALESCE(SUM(give_price),0) AS give_amount']) + ->find(); + $recharge = $recharge ? $recharge->toArray() : []; + + // 提现:按申请时间,分状态统计 + $extractFields = ['COUNT(id) AS cnt', 'COALESCE(SUM(extract_price),0) AS amount', 'COALESCE(SUM(extract_fee),0) AS fee']; + $extractSuccess = $this->extractDao->search([['status', '=', 1]]) + ->whereBetween('add_time', $range)->field($extractFields)->find(); + $extractSuccess = $extractSuccess ? $extractSuccess->toArray() : []; + $extractAudit = $this->extractDao->search([['status', '=', 0]]) + ->whereBetween('add_time', $range)->field($extractFields)->find(); + $extractAudit = $extractAudit ? $extractAudit->toArray() : []; + $extractFail = $this->extractDao->search([['status', '=', -1]]) + ->whereBetween('add_time', $range)->field(['COUNT(id) AS cnt'])->find(); + $extractFail = $extractFail ? $extractFail->toArray() : []; + + // 余额流水:仅有效记录,按记账时间统计收/支 + $flowFields = ['COUNT(id) AS cnt', 'COALESCE(SUM(number),0) AS amount']; + $moneyIn = $this->moneyDao->search([['pm', '=', 1], ['status', '=', 1]]) + ->whereBetween('add_time', $range)->field($flowFields)->find(); + $moneyIn = $moneyIn ? $moneyIn->toArray() : []; + $moneyOut = $this->moneyDao->search([['pm', '=', 0], ['status', '=', 1]]) + ->whereBetween('add_time', $range)->field($flowFields)->find(); + $moneyOut = $moneyOut ? $moneyOut->toArray() : []; + + // 佣金流水:仅有效记录,按记账时间统计收/支(收入含冻结中佣金) + $brokerageIn = $this->brokerageDao->search([['pm', '=', 1], ['status', '=', 1]]) + ->whereBetween('add_time', $range)->field($flowFields)->find(); + $brokerageIn = $brokerageIn ? $brokerageIn->toArray() : []; + $brokerageOut = $this->brokerageDao->search([['pm', '=', 0], ['status', '=', 1]]) + ->whereBetween('add_time', $range)->field($flowFields)->find(); + $brokerageOut = $brokerageOut ? $brokerageOut->toArray() : []; + + return [ + 'start_date' => $period['start_date'], + 'end_date' => $period['end_date'], + 'metrics' => [ + 'recharge' => [ + 'count' => (int)($recharge['cnt'] ?? 0), + 'amount' => (string)($recharge['amount'] ?? '0.00'), + 'give_amount' => (string)($recharge['give_amount'] ?? '0.00'), + ], + 'extract' => [ + 'success_count' => (int)($extractSuccess['cnt'] ?? 0), + 'success_amount' => (string)($extractSuccess['amount'] ?? '0.00'), + 'success_fee' => (string)($extractSuccess['fee'] ?? '0.00'), + 'auditing_count' => (int)($extractAudit['cnt'] ?? 0), + 'auditing_amount' => (string)($extractAudit['amount'] ?? '0.00'), + 'fail_count' => (int)($extractFail['cnt'] ?? 0), + ], + 'user_money' => [ + 'in_count' => (int)($moneyIn['cnt'] ?? 0), + 'in_amount' => (string)($moneyIn['amount'] ?? '0.00'), + 'out_count' => (int)($moneyOut['cnt'] ?? 0), + 'out_amount' => (string)($moneyOut['amount'] ?? '0.00'), + ], + 'user_brokerage' => [ + 'in_count' => (int)($brokerageIn['cnt'] ?? 0), + 'in_amount' => (string)($brokerageIn['amount'] ?? '0.00'), + 'out_count' => (int)($brokerageOut['cnt'] ?? 0), + 'out_amount' => (string)($brokerageOut['amount'] ?? '0.00'), + ], + ], + ]; + } +} diff --git a/backend/modules/service/mcp/tools/InvoiceListTool.php b/backend/modules/service/mcp/tools/InvoiceListTool.php new file mode 100644 index 000000000..4310cee8b --- /dev/null +++ b/backend/modules/service/mcp/tools/InvoiceListTool.php @@ -0,0 +1,136 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\order\OrderInvoiceDao; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\SensitiveData; + +/** + * MCP 工具:发票记录列表 + * + * 查询订单开票记录,开票人手机号脱敏后返回。 + */ +class InvoiceListTool implements McpToolInterface +{ + /** + * 订单发票 DAO + * @var OrderInvoiceDao + */ + private OrderInvoiceDao $dao; + + /** + * 构造函数 + * @param OrderInvoiceDao $dao 订单发票 DAO + */ + public function __construct(OrderInvoiceDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_invoice_list'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '查询订单开票记录列表,支持按开票状态、订单 ID、用户 UID 筛选;' + . 'header_type 抬头类型:1 个人、2 企业;type 发票类型:1 普通发票、2 专用发票;' + . 'is_pay 是否支付、is_refund 订单是否退款、is_invoice 是否已开票;' + . 'invoice_number 为开票票号。开票人手机号与注册电话已脱敏。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '发票记录列表'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'page' => ['type' => 'number', 'description' => '页码'], + 'limit' => ['type' => 'number', 'description' => '每页数量'], + 'uid' => ['type' => 'number', 'description' => '用户 UID'], + 'order_id' => ['type' => 'number', 'description' => '订单 ID'], + 'is_invoice' => ['type' => 'number', 'description' => '开票状态:1 已开票、0 未开票'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(page/limit/uid/order_id/is_invoice) + * @return array 发票记录列表和总数 + */ + public function execute(array $args): array + { + $page = max(1, (int)($args['page'] ?? 1)); + $limit = min(100, max(1, (int)($args['limit'] ?? 10))); + + $where = [['is_del', '=', 0]]; + if (!empty($args['uid'])) { + $where[] = ['uid', '=', (int)$args['uid']]; + } + if (!empty($args['order_id'])) { + $where[] = ['order_id', '=', (int)$args['order_id']]; + } + if (isset($args['is_invoice']) && $args['is_invoice'] !== '') { + $where[] = ['is_invoice', '=', (int)$args['is_invoice'] == 1 ? 1 : 0]; + } + + $list = $this->dao->search($where) + ->field('id,uid,order_id,header_type,type,name,duty_number,drawer_phone,email,tell,is_pay,is_refund,is_invoice,invoice_number,invoice_time,add_time') + ->order('id desc') + ->page($page, $limit) + ->select() + ->toArray(); + + foreach ($list as &$item) { + $item['drawer_phone'] = SensitiveData::phone((string)($item['drawer_phone'] ?? '')); + $item['tell'] = SensitiveData::phone((string)($item['tell'] ?? '')); + } + unset($item); + + $count = $this->dao->search($where)->count(); + return ['list' => $list, 'count' => $count]; + } +} diff --git a/backend/modules/service/mcp/tools/RechargeListTool.php b/backend/modules/service/mcp/tools/RechargeListTool.php new file mode 100644 index 000000000..2afeed57e --- /dev/null +++ b/backend/modules/service/mcp/tools/RechargeListTool.php @@ -0,0 +1,128 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserRechargeDao; +use crmeb\interfaces\McpToolInterface; + +/** + * MCP 工具:充值记录列表 + * + * 查询用户余额充值记录。 + */ +class RechargeListTool implements McpToolInterface +{ + /** + * 充值 DAO + * @var UserRechargeDao + */ + private UserRechargeDao $dao; + + /** + * 构造函数 + * @param UserRechargeDao $dao 充值 DAO + */ + public function __construct(UserRechargeDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_recharge_list'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '查询用户余额充值记录列表,支持按支付状态、充值(支付)方式、用户 UID 筛选,' + . '返回充值订单号、充值金额、赠送金额、退款金额与支付时间;' + . 'paid:1 已支付、0 未支付;recharge_type 为实际支付渠道:weixin 微信、alipay 支付宝、balance 余额。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '充值记录列表'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'page' => ['type' => 'number', 'description' => '页码'], + 'limit' => ['type' => 'number', 'description' => '每页数量'], + 'uid' => ['type' => 'number', 'description' => '用户 UID'], + 'paid' => ['type' => 'number', 'description' => '支付状态:1 已支付、0 未支付'], + 'recharge_type' => ['type' => 'string', 'description' => '充值(支付)方式:weixin/alipay/balance'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(page/limit/uid/paid/recharge_type) + * @return array 充值记录列表和总数 + */ + public function execute(array $args): array + { + $page = max(1, (int)($args['page'] ?? 1)); + $limit = min(100, max(1, (int)($args['limit'] ?? 10))); + + $where = []; + if (!empty($args['uid'])) { + $where[] = ['uid', '=', (int)$args['uid']]; + } + if (isset($args['paid']) && $args['paid'] !== '') { + $where[] = ['paid', '=', (int)$args['paid'] == 1 ? 1 : 0]; + } + if (isset($args['recharge_type']) && $args['recharge_type'] !== '') { + $where[] = ['recharge_type', '=', (string)$args['recharge_type']]; + } + + $list = $this->dao->search($where) + ->field('id,uid,order_id,price,give_price,recharge_type,paid,pay_time,refund_price,channel_type,add_time') + ->order('id desc') + ->page($page, $limit) + ->select() + ->toArray(); + + $count = $this->dao->search($where)->count(); + return ['list' => $list, 'count' => $count]; + } +} diff --git a/backend/modules/service/mcp/tools/RechargeStatisticsTool.php b/backend/modules/service/mcp/tools/RechargeStatisticsTool.php new file mode 100644 index 000000000..5d146e5d6 --- /dev/null +++ b/backend/modules/service/mcp/tools/RechargeStatisticsTool.php @@ -0,0 +1,129 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserRechargeDao; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\ReportPeriod; + +/** + * MCP 工具:充值统计 + * + * 按支付时间统计已支付充值,与后台财务统计口径一致。 + */ +class RechargeStatisticsTool implements McpToolInterface +{ + /** + * 充值 DAO + * @var UserRechargeDao + */ + private UserRechargeDao $dao; + + /** + * 构造函数 + * @param UserRechargeDao $dao 充值 DAO + */ + public function __construct(UserRechargeDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_recharge_statistics'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '按支付时间(pay_time)统计指定时间段内已支付(paid=1)的余额充值:' + . '充值笔数、充值金额、充值活动赠送金额,以及该时段内充值订单的累计退款金额。' + . '未支付记录不计入。默认近7天,最多31天。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '充值统计'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'start_date' => ['type' => 'string', 'format' => 'date', 'description' => '开始日期 YYYY-MM-DD'], + 'end_date' => ['type' => 'string', 'format' => 'date', 'description' => '结束日期 YYYY-MM-DD,包含结束日'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(start_date/end_date) + * @return array 充值统计结果 + */ + public function execute(array $args): array + { + $period = ReportPeriod::fromArgs($args, '充值统计'); + $range = [$period['start_time'], $period['end_time']]; + + $paid = $this->dao->search([['paid', '=', 1]]) + ->whereBetween('pay_time', $range) + ->field(['COUNT(id) AS cnt', 'COALESCE(SUM(price),0) AS amount', 'COALESCE(SUM(give_price),0) AS give_amount']) + ->find(); + $paid = $paid ? $paid->toArray() : []; + + $refunded = $this->dao->search([['paid', '=', 1], ['refund_price', '>', 0]]) + ->whereBetween('pay_time', $range) + ->field(['COUNT(id) AS cnt', 'COALESCE(SUM(refund_price),0) AS amount']) + ->find(); + $refunded = $refunded ? $refunded->toArray() : []; + + return [ + 'start_date' => $period['start_date'], + 'end_date' => $period['end_date'], + 'metrics' => [ + 'recharge_count' => (int)($paid['cnt'] ?? 0), + 'recharge_amount' => (string)($paid['amount'] ?? '0.00'), + 'give_amount' => (string)($paid['give_amount'] ?? '0.00'), + 'refund_count' => (int)($refunded['cnt'] ?? 0), + 'refund_amount' => (string)($refunded['amount'] ?? '0.00'), + ], + ]; + } +} diff --git a/backend/modules/service/mcp/tools/UserBrokerageListTool.php b/backend/modules/service/mcp/tools/UserBrokerageListTool.php new file mode 100644 index 000000000..fb1472293 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserBrokerageListTool.php @@ -0,0 +1,135 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserBrokerageDao; +use crmeb\interfaces\McpToolInterface; + +/** + * MCP 工具:佣金流水列表 + * + * v8 起分销佣金变动记于独立账单表 user_brokerage。 + */ +class UserBrokerageListTool implements McpToolInterface +{ + /** + * 佣金流水 DAO + * @var UserBrokerageDao + */ + private UserBrokerageDao $dao; + + /** + * 构造函数 + * @param UserBrokerageDao $dao 佣金流水 DAO + */ + public function __construct(UserBrokerageDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_brokerage_list'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '查询用户分销佣金流水列表(v8 独立佣金账单表),支持按用户 UID、类型、收/支方向、状态筛选,' + . '返回变动金额、变动后佣金、冻结到期时间与备注;' + . 'type 常见值:self_brokerage 自购订单佣金、one_brokerage/two_brokerage 一/二级推广订单佣金、' + . 'brokerage_user 推广用户佣金、extract 佣金提现、extract_fail 提现失败退回、extract_money 提现到余额、' + . 'refund 退款退回佣金、pink_master_brokerage 拼团团长佣金;' + . 'pm:1 收入、0 支出;status:1 有效、0 待确定、-1 无效,冻结中的佣金以 frozen_time 大于当前时间判定。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '佣金流水列表'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'page' => ['type' => 'number', 'description' => '页码'], + 'limit' => ['type' => 'number', 'description' => '每页数量'], + 'uid' => ['type' => 'number', 'description' => '用户 UID'], + 'type' => ['type' => 'string', 'description' => '变动类型,如 one_brokerage/extract/refund'], + 'pm' => ['type' => 'number', 'description' => '收/支方向:1 收入、0 支出'], + 'status' => ['type' => 'number', 'description' => '状态:1 有效、0 待确定、-1 无效'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(page/limit/uid/type/pm/status) + * @return array 佣金流水列表和总数 + */ + public function execute(array $args): array + { + $page = max(1, (int)($args['page'] ?? 1)); + $limit = min(100, max(1, (int)($args['limit'] ?? 10))); + + $where = []; + if (!empty($args['uid'])) { + $where[] = ['uid', '=', (int)$args['uid']]; + } + if (isset($args['type']) && $args['type'] !== '') { + $where[] = ['type', '=', (string)$args['type']]; + } + if (isset($args['pm']) && $args['pm'] !== '') { + $where[] = ['pm', '=', (int)$args['pm'] == 1 ? 1 : 0]; + } + if (isset($args['status']) && $args['status'] !== '') { + $where[] = ['status', '=', (int)$args['status']]; + } + + $list = $this->dao->search($where) + ->field('id,uid,link_id,type,title,number,balance,pm,mark,status,frozen_time,add_time') + ->order('id desc') + ->page($page, $limit) + ->select() + ->toArray(); + + $count = $this->dao->search($where)->count(); + return ['list' => $list, 'count' => $count]; + } +} diff --git a/backend/modules/service/mcp/tools/UserEditPayload.php b/backend/modules/service/mcp/tools/UserEditPayload.php new file mode 100644 index 000000000..05ba8eb8e --- /dev/null +++ b/backend/modules/service/mcp/tools/UserEditPayload.php @@ -0,0 +1,170 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelRelationServices; +use app\services\user\UserServices; +use modules\service\mcp\McpException; + +/** + * 用户编辑数据组装辅助(MCP 内部共享,非工具类) + * + * UserServices::updateInfo 走基本信息分支时按全字段覆盖写库,并以 label_id + * 全量重设用户标签;本类负责先读用户当前值,合并工具显式传入的修改项后 + * 构造 updateInfo 所需的完整 $data,保证「只改一项」不会清空其他资料, + * 与管理后台编辑表单(读出当前值 → 改一项 → 提交)链路一致。 + * + * 文件名不以 Tool.php 结尾,不会被注册中心自动发现,仅供用户编辑类工具复用。 + */ +final class UserEditPayload +{ + /** + * 构造 UserServices::updateInfo 所需的全量编辑数据 + * + * @param UserServices $services 用户服务(用于读取用户与手机号唯一性校验) + * @param UserLabelRelationServices $labelServices 用户标签关联服务(读取当前标签) + * @param int $uid 用户 ID + * @param array $overrides 显式要修改的字段(仅支持 real_name/phone/email/card_id/birthday/addres/mark/status/group_id/label_ids) + * @return array updateInfo 完整入参 + * @throws McpException 用户不存在或字段校验失败 + */ + public static function build(UserServices $services, UserLabelRelationServices $labelServices, int $uid, array $overrides): array + { + $user = $services->getUserInfo($uid); + if (!$user) { + throw new McpException('用户不存在'); + } + $user = is_object($user) ? $user->toArray() : (array)$user; + + // 真实姓名(对齐后台必填与 eb_user.real_name varchar(25)) + $realName = trim((string)($overrides['real_name'] ?? $user['real_name'] ?? '')); + if ($realName === '') { + throw new McpException('real_name 真实姓名不能为空'); + } + if (mb_strlen($realName) > 25) { + throw new McpException('real_name 真实姓名不能超过 25 个字'); + } + + // 手机号:必填、格式与全库唯一(对齐 UserController::update 校验) + $phone = trim((string)($overrides['phone'] ?? $user['phone'] ?? '')); + if ($phone === '') { + throw new McpException('phone 手机号不能为空'); + } + if (!preg_match('/^1[3456789]\d{9}$/', $phone)) { + throw new McpException('phone 手机号格式错误'); + } + if ($phone !== (string)($user['phone'] ?? '') + && $services->count(['phone' => $phone, 'is_del' => 0, 'not_uid' => $uid])) { + throw new McpException('phone 手机号已被其他用户使用'); + } + + // 邮箱:可空,填了必须合法 + $email = trim((string)($overrides['email'] ?? $user['email'] ?? '')); + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new McpException('email 邮箱格式错误'); + } + + // 身份证:可空,填了必须合法(全局辅助函数校验) + $cardId = trim((string)($overrides['card_id'] ?? $user['card_id'] ?? '')); + if ($cardId !== '' && !check_card($cardId)) { + throw new McpException('card_id 身份证号码不正确'); + } + + // 生日:传日期字符串(YYYY-MM-DD);未传时回填当前值(时间戳 0 回填纪元日,strtotime 后仍近似为空) + if (array_key_exists('birthday', $overrides)) { + $birthday = trim((string)$overrides['birthday']); + if ($birthday !== '' && strtotime($birthday) === false) { + throw new McpException('birthday 必须是有效日期,格式 YYYY-MM-DD'); + } + } else { + $birthdayTimestamp = (int)($user['birthday'] ?? 0); + $birthday = $birthdayTimestamp > 0 ? date('Y-m-d H:i:s', $birthdayTimestamp) : '1970-01-01 08:00:00'; + } + + // 状态:1=开启,0=锁定 + $status = (int)($overrides['status'] ?? $user['status'] ?? 1); + if (!in_array($status, [0, 1], true)) { + throw new McpException('status 只能为 0(锁定)或 1(开启)'); + } + + // 备注(eb_user.mark varchar(255)) + $mark = trim((string)($overrides['mark'] ?? $user['mark'] ?? '')); + if (mb_strlen($mark) > 255) { + throw new McpException('mark 备注不能超过 255 个字'); + } + + // 详细地址 + $addres = trim((string)($overrides['addres'] ?? $user['addres'] ?? '')); + if (mb_strlen($addres) > 255) { + throw new McpException('addres 详细地址不能超过 255 个字'); + } + + // 分组:0 表示未分组 + $groupId = max(0, (int)($overrides['group_id'] ?? $user['group_id'] ?? 0)); + + // 标签:未显式传入时回填当前标签,避免 updateInfo 以空数组全量清空 + if (array_key_exists('label_ids', $overrides)) { + $labelIds = self::idArray($overrides['label_ids'], 'label_ids'); + } else { + $labelIds = array_map('intval', (array)$labelServices->getUserLabels($uid)); + } + + // updateInfo 基本信息分支要求的全量结构;等级/余额/积分不在编辑范围, + // 保持当前值避免触发分支逻辑,密码固定空串即不修改 + return [ + 'money_status' => 0, + 'money' => '0', + 'integration_status' => 0, + 'integration' => '0', + 'is_other' => false, + 'label_id' => $labelIds, + 'pwd' => '', + 'status' => $status, + 'real_name' => $realName, + 'card_id' => $cardId, + 'birthday' => $birthday, + 'mark' => $mark, + 'level' => (int)($user['level'] ?? 0), + 'phone' => $phone, + 'addres' => $addres, + 'email' => $email, + 'group_id' => $groupId, + ]; + } + + /** + * 校验并规范化正整数 ID 数组(去重) + * + * @param mixed $value 原始值 + * @param string $name 参数名称(报错用) + * @return int[] ID 列表(允许为空数组) + * @throws McpException 不是合法 ID 数组 + */ + public static function idArray(mixed $value, string $name): array + { + if (is_int($value) || (is_string($value) && $value !== '')) { + $value = [$value]; + } + if (!is_array($value)) { + throw new McpException($name . ' 必须是 ID 数组'); + } + $ids = []; + foreach ($value as $item) { + if (!is_int($item) && (!is_string($item) || !ctype_digit($item))) { + throw new McpException($name . ' 数组内必须全部是整数 ID'); + } + $ids[] = (int)$item; + } + return array_values(array_unique($ids)); + } +} diff --git a/backend/modules/service/mcp/tools/UserGroupSetTool.php b/backend/modules/service/mcp/tools/UserGroupSetTool.php new file mode 100644 index 000000000..ada74c8a5 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserGroupSetTool.php @@ -0,0 +1,141 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:设置用户分组 + * + * 与管理后台「设置分组」同链路:调用 UserServices::saveSetGroup 批量 + * 将用户调整为指定分组;分组存在性与写入结果由服务层校验。 + */ +class UserGroupSetTool implements McpToolInterface +{ + /** + * 用户服务 + * @var UserServices + */ + private UserServices $services; + + /** + * 构造函数 + * @param UserServices $services 用户服务 + */ + public function __construct(UserServices $services) + { + $this->services = $services; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_group_set'; + } + + /** + * 交互声明:调整用户分组会变更用户数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '把一个或多个用户设置到指定用户分组,等同管理后台「设置分组」,成功返回本次调整的分组与用户数。' + . '必填参数:uids 用户ID数组(支持批量);group_id 分组ID(大于 0,必须是已存在的用户分组,分组清单在管理后台「用户 → 用户分组」维护,' + . '目前没有查询分组的 MCP 工具,ID 不确定时先向运营确认)。' + . '本工具是覆盖式设置:执行后所选用户的分组统一变为 group_id,原分组被替换。' + . '不能创建或删除分组,不能把用户移出分组(设为其他分组即是替换);设置用户标签用 crmeb_user_label_set。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '分组设置'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['uids', 'group_id'], + 'properties' => [ + 'uids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => '用户ID数组,支持批量设置'], + 'group_id' => ['type' => 'integer', 'minimum' => 1, 'description' => '目标分组ID,必须是已存在的用户分组'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(uids、group_id) + * @return array 本次分组调整结果 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $uids = UserEditPayload::idArray($args['uids'] ?? [], 'uids'); + $uids = array_values(array_filter($uids, static fn(int $id) => $id > 0)); + if (!$uids) { + throw new McpException('uids 必须是至少一个大于 0 的用户ID数组'); + } + $groupId = (int)($args['group_id'] ?? 0); + if ($groupId <= 0) { + throw new McpException('group_id 必须是大于 0 的分组ID'); + } + + try { + $this->services->saveSetGroup($uids, $groupId); + } catch (AdminException $e) { + // 「该分组不存在」「设置分组失败或无改动」等业务错误透传 + throw new McpException($e->getMessage()); + } + + return [ + 'group_id' => $groupId, + 'uid_count' => count($uids), + 'uids' => $uids, + 'message' => '已把 ' . count($uids) . ' 个用户设置到分组 ' . $groupId, + ]; + } +} diff --git a/backend/modules/service/mcp/tools/UserLabelCreateTool.php b/backend/modules/service/mcp/tools/UserLabelCreateTool.php new file mode 100644 index 000000000..7b383c38e --- /dev/null +++ b/backend/modules/service/mcp/tools/UserLabelCreateTool.php @@ -0,0 +1,182 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelCateServices; +use app\services\user\UserLabelServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:创建用户标签 + * + * 与管理后台「用户标签」同链路:调用 UserLabelServices::save 新建标签; + * 参数预检在工具层完成(McpException 消息可透传),服务层业务校验不因 AI 调用而豁免。 + */ +class UserLabelCreateTool implements McpToolInterface +{ + /** + * 用户标签服务 + * @var UserLabelServices + */ + private UserLabelServices $services; + + /** + * 标签分类服务 + * @var UserLabelCateServices + */ + private UserLabelCateServices $cateServices; + + /** + * 构造函数 + * @param UserLabelServices $services 用户标签服务 + * @param UserLabelCateServices $cateServices 标签分类服务 + */ + public function __construct(UserLabelServices $services, UserLabelCateServices $cateServices) + { + $this->services = $services; + $this->cateServices = $cateServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_label_create'; + } + + /** + * 交互声明:创建标签会新增系统配置数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '创建一个用户标签(管理后台「用户标签」体系),成功后即可通过后台给用户打标;返回创建结果摘要;' + . '必填参数:label_name 标签名称(非空,50 字以内);label_cate 标签分类 ID(大于 0 的整数,' + . '必须是已存在的分类,可用分类见 crmeb_user_label_list 返回的 cates 字段);' + . '同一分类下不允许与既有标签重名;创建成功后可通过 crmeb_user_label_list 查询确认,' + . '修改已有标签用 crmeb_user_label_update,删除标签用 crmeb_user_label_delete。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '标签创建'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['label_name', 'label_cate'], + 'properties' => [ + 'label_name' => ['type' => 'string', 'maxLength' => 50, 'description' => '标签名称,非空,50 字以内'], + 'label_cate' => ['type' => 'integer', 'minimum' => 1, 'description' => '标签分类 ID,必须是已存在的分类,可用分类见 crmeb_user_label_list 返回的 cates'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(label_name/label_cate) + * @return array 创建结果摘要 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $name = trim((string)($args['label_name'] ?? '')); + if ($name === '') { + throw new McpException('label_name 不能为空'); + } + if (mb_strlen($name) > 50) { + throw new McpException('label_name 不能超过 50 个字'); + } + $cateId = self::positiveIntegerArg($args, 'label_cate'); + if (!$this->cateExists($cateId)) { + throw new McpException('label_cate 标签分类不存在,可用分类见 crmeb_user_label_list 返回的 cates'); + } + + // 与 UserLabelController::save 的 postMore 结构保持一致,由服务层完成入库与查重 + try { + $this->services->save(0, ['id' => 0, 'label_cate' => $cateId, 'label_name' => $name]); + } catch (AdminException $e) { + // 服务层业务校验失败:转 McpException 让消息透传给 AI 客户端 + throw new McpException($e->getMessage()); + } + + return [ + 'label_name' => $name, + 'label_cate' => $cateId, + 'message' => '用户标签创建成功,可通过 crmeb_user_label_list 查询确认', + ]; + } + + /** + * 校验标签分类是否存在 + * @param int $cateId 分类 ID + * @return bool 是否存在 + */ + private function cateExists(int $cateId): bool + { + $cateIds = array_map('intval', array_column($this->cateServices->getLabelCateAll(), 'id')); + return in_array($cateId, $cateIds, true); + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @return int 大于 0 的整数 + * @throws McpException 非整数或小于等于 0 + */ + private static function positiveIntegerArg(array $args, string $name): int + { + $value = $args[$name] ?? null; + if (!is_int($value) && (!is_string($value) || !ctype_digit($value))) { + throw new McpException($name . ' 必须是大于 0 的整数'); + } + $number = (int)$value; + if ($number <= 0) { + throw new McpException($name . ' 必须是大于 0 的整数'); + } + return $number; + } +} diff --git a/backend/modules/service/mcp/tools/UserLabelDeleteTool.php b/backend/modules/service/mcp/tools/UserLabelDeleteTool.php new file mode 100644 index 000000000..f3e94fb3e --- /dev/null +++ b/backend/modules/service/mcp/tools/UserLabelDeleteTool.php @@ -0,0 +1,157 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:删除用户标签 + * + * 与管理后台「用户标签」同链路:调用 UserLabelServices::delLabel 删除标签; + * 参数预检在工具层完成(McpException 消息可透传),服务层业务校验不因 AI 调用而豁免。 + */ +class UserLabelDeleteTool implements McpToolInterface +{ + /** + * 用户标签服务 + * @var UserLabelServices + */ + private UserLabelServices $services; + + /** + * 构造函数 + * @param UserLabelServices $services 用户标签服务 + */ + public function __construct(UserLabelServices $services) + { + $this->services = $services; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_label_delete'; + } + + /** + * 交互声明:删除标签会不可逆地移除系统配置数据,需用户确认后才真实执行; + * delete=删除类写操作,后台权限勾选与工具列表按「删」标注展示 + * @return string delete=删除类,需确认后执行 + */ + public function interaction(): string + { + return 'delete'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '删除一个用户标签,删除后该标签从标签列表消失且不可恢复;' + . '必填参数:id 标签 ID(可通过 crmeb_user_label_list 查询);' + . '仅删除标签本身,不清理用户与该标签的既有关联记录(与管理后台删除行为一致),' + . '删除前建议先用 crmeb_user_list 按 label_id 筛选确认受影响用户;' + . '创建标签用 crmeb_user_label_create,修改标签用 crmeb_user_label_update。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '标签删除'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1, 'description' => '待删除的标签 ID,可通过 crmeb_user_label_list 查询'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(id) + * @return array 删除结果摘要 + * @throws McpException 参数非法、标签不存在或服务层业务校验失败 + */ + public function execute(array $args): array + { + $id = self::positiveIntegerArg($args, 'id'); + + $label = $this->services->getLable($id); + if (!$label) { + throw new McpException('标签不存在,可通过 crmeb_user_label_list 查询有效标签 ID'); + } + $name = (string)$label->getData('label_name'); + + try { + $this->services->delLabel($id); + } catch (AdminException $e) { + // 服务层业务校验失败:转 McpException 让消息透传给 AI 客户端 + throw new McpException($e->getMessage()); + } + + return [ + 'id' => $id, + 'label_name' => $name, + 'message' => '用户标签删除成功,可通过 crmeb_user_label_list 查询确认', + ]; + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @return int 大于 0 的整数 + * @throws McpException 非整数或小于等于 0 + */ + private static function positiveIntegerArg(array $args, string $name): int + { + $value = $args[$name] ?? null; + if (!is_int($value) && (!is_string($value) || !ctype_digit($value))) { + throw new McpException($name . ' 必须是大于 0 的整数'); + } + $number = (int)$value; + if ($number <= 0) { + throw new McpException($name . ' 必须是大于 0 的整数'); + } + return $number; + } +} diff --git a/backend/modules/service/mcp/tools/UserLabelListTool.php b/backend/modules/service/mcp/tools/UserLabelListTool.php new file mode 100644 index 000000000..7ce894187 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserLabelListTool.php @@ -0,0 +1,125 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelCateServices; +use app\services\user\UserLabelServices; +use crmeb\interfaces\McpToolInterface; + +/** + * MCP 工具:用户标签列表 + * + * 标签字典查询:返回全部用户标签及所属分类,供 crmeb_user_list 按标签筛选时取标签 ID。 + */ +class UserLabelListTool implements McpToolInterface +{ + /** + * 用户标签服务 + * @var UserLabelServices + */ + private UserLabelServices $services; + + /** + * 标签分类服务 + * @var UserLabelCateServices + */ + private UserLabelCateServices $cateServices; + + /** + * 构造函数 + * @param UserLabelServices $services 用户标签服务 + * @param UserLabelCateServices $cateServices 标签分类服务 + */ + public function __construct(UserLabelServices $services, UserLabelCateServices $cateServices) + { + $this->services = $services; + $this->cateServices = $cateServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_label_list'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '查询全部用户标签字典,返回标签 ID、标签名称、所属分类 ID 与分类名,并附全部标签分类列表(cates 字段);' + . 'label_cate 可选,按标签分类 ID 筛选标签,0 或不传返回全部分类下的标签;' + . '不分页全量返回,不包含各标签下的打标用户数;' + . '标签 ID 用于 crmeb_user_list 的 label_id 参数按标签筛选用户。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '标签列表'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'label_cate' => ['type' => 'integer', 'minimum' => 0, 'description' => '标签分类 ID,按分类筛选标签;0 或不传返回全部'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(label_cate) + * @return array 标签列表、分类列表与总数 + */ + public function execute(array $args): array + { + $where = []; + $cateId = (int)($args['label_cate'] ?? 0); + if ($cateId > 0) { + $where['label_cate'] = $cateId; + } + + $list = $this->services->getLabelList($where, ['id', 'label_name', 'label_cate']); + + $cates = []; + foreach ($this->cateServices->getLabelCateAll() as $cate) { + $cates[] = ['id' => (int)($cate['id'] ?? 0), 'name' => (string)($cate['name'] ?? '')]; + } + + return ['list' => $list, 'count' => count($list), 'cates' => $cates]; + } +} diff --git a/backend/modules/service/mcp/tools/UserLabelSetTool.php b/backend/modules/service/mcp/tools/UserLabelSetTool.php new file mode 100644 index 000000000..60a9d9230 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserLabelSetTool.php @@ -0,0 +1,165 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:设置用户标签 + * + * 与管理后台「设置标签」同链路:调用 UserServices::saveSetLabel 批量 + * 覆盖 / 追加 / 移除用户标签;标签存在性与写入结果由服务层校验。 + */ +class UserLabelSetTool implements McpToolInterface +{ + /** + * 标签操作类型:清空原有后设置(覆盖) + */ + private const LABEL_TYPE_REPLACE = 0; + + /** + * 标签操作类型:保留原有叠加(追加) + */ + private const LABEL_TYPE_APPEND = 1; + + /** + * 标签操作类型:移除指定标签 + */ + private const LABEL_TYPE_REMOVE = 2; + + /** + * 用户服务 + * @var UserServices + */ + private UserServices $services; + + /** + * 构造函数 + * @param UserServices $services 用户服务 + */ + public function __construct(UserServices $services) + { + $this->services = $services; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_label_set'; + } + + /** + * 交互声明:调整用户标签会变更用户数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '为一个或多个用户设置用户标签,等同管理后台「设置标签」,支持覆盖、追加、移除三种方式,成功返回本次操作的标签与用户数。' + . '必填参数:uids 用户ID数组(支持批量);label_ids 标签ID数组。' + . 'label_type 操作方式:0=覆盖(默认,清空用户原有标签后设置 label_ids)、1=追加(保留原有标签叠加 label_ids)、' + . '2=移除(从用户身上移除 label_ids 指定的标签)。' + . 'label_ids 必须是已存在的用户标签,标签 ID 与分类清单可用 crmeb_user_label_list 查询(创建新标签用 crmeb_user_label_create),' + . '标签不存在时服务层会整体拒绝。' + . '不能创建或删除标签本身;改用户分组用 crmeb_user_group_set。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '标签设置'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['uids', 'label_ids'], + 'properties' => [ + 'uids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => '用户ID数组,支持批量设置'], + 'label_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => '标签ID数组;label_type=2 时为要移除的标签'], + 'label_type' => ['type' => 'integer', 'enum' => [0, 1, 2], 'description' => '0=覆盖原有标签(默认),1=追加标签,2=移除指定标签'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(uids、label_ids、label_type) + * @return array 本次标签操作结果 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $uids = UserEditPayload::idArray($args['uids'] ?? [], 'uids'); + $uids = array_values(array_filter($uids, static fn(int $id) => $id > 0)); + if (!$uids) { + throw new McpException('uids 必须是至少一个大于 0 的用户ID数组'); + } + $labelIds = UserEditPayload::idArray($args['label_ids'] ?? [], 'label_ids'); + if (!$labelIds) { + throw new McpException('label_ids 必须是至少一个标签ID'); + } + $labelType = (int)($args['label_type'] ?? self::LABEL_TYPE_REPLACE); + if (!in_array($labelType, [self::LABEL_TYPE_REPLACE, self::LABEL_TYPE_APPEND, self::LABEL_TYPE_REMOVE], true)) { + throw new McpException('label_type 只能是 0(覆盖)、1(追加)或 2(移除)'); + } + + try { + $this->services->saveSetLabel($uids, $labelIds, $labelType); + } catch (AdminException $e) { + // 「有标签不存在或被删除」「设置标签失败」等业务错误透传 + throw new McpException($e->getMessage()); + } + + $actionText = [self::LABEL_TYPE_REPLACE => '覆盖为', self::LABEL_TYPE_APPEND => '追加', self::LABEL_TYPE_REMOVE => '移除'][$labelType]; + return [ + 'label_type' => $labelType, + 'label_ids' => $labelIds, + 'uid_count' => count($uids), + 'uids' => $uids, + 'message' => '已把 ' . count($uids) . ' 个用户的标签' . $actionText . ' ' . implode(',', $labelIds), + ]; + } +} diff --git a/backend/modules/service/mcp/tools/UserLabelUpdateTool.php b/backend/modules/service/mcp/tools/UserLabelUpdateTool.php new file mode 100644 index 000000000..8c8d77152 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserLabelUpdateTool.php @@ -0,0 +1,199 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelCateServices; +use app\services\user\UserLabelServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:修改用户标签 + * + * 与管理后台「用户标签」同链路:调用 UserLabelServices::save 更新已有标签; + * 未提供的参数沿用原值;参数预检在工具层完成(McpException 消息可透传), + * 服务层业务校验不因 AI 调用而豁免。 + */ +class UserLabelUpdateTool implements McpToolInterface +{ + /** + * 用户标签服务 + * @var UserLabelServices + */ + private UserLabelServices $services; + + /** + * 标签分类服务 + * @var UserLabelCateServices + */ + private UserLabelCateServices $cateServices; + + /** + * 构造函数 + * @param UserLabelServices $services 用户标签服务 + * @param UserLabelCateServices $cateServices 标签分类服务 + */ + public function __construct(UserLabelServices $services, UserLabelCateServices $cateServices) + { + $this->services = $services; + $this->cateServices = $cateServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_label_update'; + } + + /** + * 交互声明:修改标签会变更系统配置数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '修改一个已有用户标签的名称或所属分类,未提供的字段沿用原值,返回修改后的标签信息;' + . '必填参数:id 标签 ID(可通过 crmeb_user_label_list 查询);' + . '可选参数:label_name 新名称(非空,50 字以内)、label_cate 新分类 ID(必须是已存在的分类,' + . '可用分类见 crmeb_user_label_list 返回的 cates 字段),两者至少提供一个;' + . '同一分类下不允许与其他标签重名;创建标签用 crmeb_user_label_create,删除标签用 crmeb_user_label_delete。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '标签修改'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1, 'description' => '标签 ID,可通过 crmeb_user_label_list 查询'], + 'label_name' => ['type' => 'string', 'maxLength' => 50, 'description' => '新标签名称,非空,50 字以内;不传沿用原值'], + 'label_cate' => ['type' => 'integer', 'minimum' => 1, 'description' => '新标签分类 ID,必须是已存在的分类;不传沿用原值'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(id,label_name/label_cate 至少其一) + * @return array 修改后的标签信息 + * @throws McpException 参数非法、标签不存在或服务层业务校验失败 + */ + public function execute(array $args): array + { + $id = self::positiveIntegerArg($args, 'id'); + if (!isset($args['label_name']) && !isset($args['label_cate'])) { + throw new McpException('label_name 与 label_cate 至少提供一个'); + } + + $label = $this->services->getLable($id); + if (!$label) { + throw new McpException('标签不存在,可通过 crmeb_user_label_list 查询有效标签 ID'); + } + + $name = trim((string)($args['label_name'] ?? $label->getData('label_name'))); + if ($name === '') { + throw new McpException('label_name 不能为空'); + } + if (mb_strlen($name) > 50) { + throw new McpException('label_name 不能超过 50 个字'); + } + + $cateId = (int)$label->getData('label_cate'); + if (isset($args['label_cate'])) { + $cateId = self::positiveIntegerArg($args, 'label_cate'); + if (!$this->cateExists($cateId)) { + throw new McpException('label_cate 标签分类不存在,可用分类见 crmeb_user_label_list 返回的 cates'); + } + } + + // 与 UserLabelController::save 的 postMore 结构保持一致,由服务层完成入库与查重 + try { + $this->services->save($id, ['id' => $id, 'label_cate' => $cateId, 'label_name' => $name]); + } catch (AdminException $e) { + // 服务层业务校验失败:转 McpException 让消息透传给 AI 客户端 + throw new McpException($e->getMessage()); + } + + return [ + 'id' => $id, + 'label_name' => $name, + 'label_cate' => $cateId, + 'message' => '用户标签修改成功,可通过 crmeb_user_label_list 查询确认', + ]; + } + + /** + * 校验标签分类是否存在 + * @param int $cateId 分类 ID + * @return bool 是否存在 + */ + private function cateExists(int $cateId): bool + { + $cateIds = array_map('intval', array_column($this->cateServices->getLabelCateAll(), 'id')); + return in_array($cateId, $cateIds, true); + } + + /** + * @param array $args 工具参数 + * @param string $name 参数名称 + * @return int 大于 0 的整数 + * @throws McpException 非整数或小于等于 0 + */ + private static function positiveIntegerArg(array $args, string $name): int + { + $value = $args[$name] ?? null; + if (!is_int($value) && (!is_string($value) || !ctype_digit($value))) { + throw new McpException($name . ' 必须是大于 0 的整数'); + } + $number = (int)$value; + if ($number <= 0) { + throw new McpException($name . ' 必须是大于 0 的整数'); + } + return $number; + } +} diff --git a/backend/modules/service/mcp/tools/UserLevelGiveTool.php b/backend/modules/service/mcp/tools/UserLevelGiveTool.php new file mode 100644 index 000000000..d646df371 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserLevelGiveTool.php @@ -0,0 +1,141 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:赠送会员等级 + * + * 与管理后台「赠送等级」同链路:调用 UserServices::saveGiveLevel 给用户 + * 赠送系统会员等级配置中的一个等级;等级存在性、重复赠送由服务层校验, + * 赠送成功后用户的 level 字段同步为该等级 ID,原等级作废。 + */ +class UserLevelGiveTool implements McpToolInterface +{ + /** + * 用户服务 + * @var UserServices + */ + private UserServices $services; + + /** + * 构造函数 + * @param UserServices $services 用户服务 + */ + public function __construct(UserServices $services) + { + $this->services = $services; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_level_give'; + } + + /** + * 交互声明:赠送会员等级会变更用户权益数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '给单个用户赠送系统会员等级,等同管理后台「赠送等级」,成功返回赠送的等级信息。' + . '必填参数:uid 用户ID;level_id 等级ID(系统会员等级配置「会员 → 等级设置」的主键 ID,' + . '不是等级序数;目前没有查询等级配置的 MCP 工具,ID 不确定时先向运营确认)。' + . '行为口径:赠送后用户原会员等级记录被作废并替换为新等级,用户 level 字段同步为 level_id,' + . '等级附带经验差异会自动增减;用户已拥有该等级且未失效时赠送失败;只能赠等级,不能清除等级或修改经验。' + . '注意与付费会员是两套体系:延长付费会员到期时间用 crmeb_user_member_time_give。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '赠送等级'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['uid', 'level_id'], + 'properties' => [ + 'uid' => ['type' => 'number', 'description' => '用户ID'], + 'level_id' => ['type' => 'integer', 'minimum' => 1, 'description' => '系统会员等级配置ID(会员等级设置列表的主键,非等级序数)'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(uid、level_id) + * @return array 赠送结果 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $uid = (int)($args['uid'] ?? 0); + if ($uid <= 0) { + throw new McpException('参数错误:缺少 uid 或格式不正确'); + } + $levelId = (int)($args['level_id'] ?? 0); + if ($levelId <= 0) { + throw new McpException('level_id 必须是大于 0 的等级ID'); + } + + try { + $this->services->saveGiveLevel($uid, $levelId); + } catch (AdminException $e) { + // 「用户不存在」「等级不存在或被删除」「已有该等级」等业务错误透传 + throw new McpException($e->getMessage()); + } + + return [ + 'uid' => $uid, + 'level_id' => $levelId, + 'message' => '会员等级赠送成功,用户当前等级已设置为 ' . $levelId . ',可通过 crmeb_user_detail 查看', + ]; + } +} diff --git a/backend/modules/service/mcp/tools/UserMarkSetTool.php b/backend/modules/service/mcp/tools/UserMarkSetTool.php new file mode 100644 index 000000000..75c4413c8 --- /dev/null +++ b/backend/modules/service/mcp/tools/UserMarkSetTool.php @@ -0,0 +1,150 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelRelationServices; +use app\services\user\UserServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:设置用户备注 + * + * 与管理后台「编辑用户」同链路:读取用户当前值合并新备注后调用 + * UserServices::updateInfo 落库,只改 mark 一个字段,不影响其他资料。 + */ +class UserMarkSetTool implements McpToolInterface +{ + /** + * 用户服务 + * @var UserServices + */ + private UserServices $services; + + /** + * 用户标签关联服务(读取当前标签避免被清空) + * @var UserLabelRelationServices + */ + private UserLabelRelationServices $labelServices; + + /** + * 构造函数 + * @param UserServices $services 用户服务 + * @param UserLabelRelationServices $labelServices 用户标签关联服务 + */ + public function __construct(UserServices $services, UserLabelRelationServices $labelServices) + { + $this->services = $services; + $this->labelServices = $labelServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_mark_set'; + } + + /** + * 交互声明:写备注会变更用户数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '给单个用户写运营备注(mark 字段),等同管理后台「编辑用户」里的备注项;只修改备注,不影响昵称、手机号、分组、标签等其他资料,成功返回更新后的备注。' + . '必填参数:uid 用户ID;mark 备注内容(255 字以内,传空字符串即清空备注)。' + . '备注面向运营人员记录,用户端不可见;需要修改姓名、手机号等完整资料时用 crmeb_user_update。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '用户备注'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['uid', 'mark'], + 'properties' => [ + 'uid' => ['type' => 'number', 'description' => '用户ID'], + 'mark' => ['type' => 'string', 'maxLength' => 255, 'description' => '备注内容,255 字以内,空字符串表示清空备注'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(uid、mark) + * @return array 更新后的备注 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $uid = (int)($args['uid'] ?? 0); + if ($uid <= 0) { + throw new McpException('参数错误:缺少 uid 或格式不正确'); + } + if (!array_key_exists('mark', $args) || !is_string($args['mark'])) { + throw new McpException('参数错误:缺少 mark 或格式不正确'); + } + $mark = trim($args['mark']); + if (mb_strlen($mark) > 255) { + throw new McpException('mark 备注不能超过 255 个字'); + } + + $data = UserEditPayload::build($this->services, $this->labelServices, $uid, ['mark' => $mark]); + + try { + $this->services->updateInfo($uid, $data); + } catch (AdminException $e) { + throw new McpException($e->getMessage()); + } + + return [ + 'uid' => $uid, + 'mark' => $mark, + 'message' => $mark === '' ? '用户备注已清空' : '用户备注已更新,可通过 crmeb_user_detail 查看', + ]; + } +} diff --git a/backend/modules/service/mcp/tools/UserMemberTimeGiveTool.php b/backend/modules/service/mcp/tools/UserMemberTimeGiveTool.php new file mode 100644 index 000000000..51905363f --- /dev/null +++ b/backend/modules/service/mcp/tools/UserMemberTimeGiveTool.php @@ -0,0 +1,173 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:赠送付费会员时长 + * + * 与管理后台「赠送付费会员时长」同链路:调用 UserServices::saveGiveLevelTime + * 给用户增加付费会员有效期(或设为永久);服务层会写入会员赠送订单并更新 + * 会员到期时间(overdue_time)与永久会员标记(is_ever_level)。 + */ +class UserMemberTimeGiveTool implements McpToolInterface +{ + /** + * 天数取值:永久会员 + */ + private const DAYS_FOREVER = -1; + + /** + * 用户服务 + * @var UserServices + */ + private UserServices $services; + + /** + * 构造函数 + * @param UserServices $services 用户服务 + */ + public function __construct(UserServices $services) + { + $this->services = $services; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_member_time_give'; + } + + /** + * 交互声明:赠送付费会员会变更用户权益数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '给单个用户赠送付费会员时长(延长会员到期时间或设为永久会员),等同管理后台「赠送付费会员时长」,成功返回赠送后的会员到期时间。' + . '必填参数:uid 用户ID;days 赠送天数:-1=设为永久会员,正整数=在当前到期时间基础上增加天数(未开通过付费会员的用户从当前时间起算)。' + . 'days=0 或小于 -1 会被拒绝;已是永久会员时无需赠送,工具会直接返回提示。' + . '赠送会写入一条后台赠送的会员订单记录。注意与会员等级是两套体系:调整会员等级用 crmeb_user_level_give;' + . '本工具不能扣减时长(不支持负数天数)、不能关闭付费会员资格。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '赠送会员时长'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['uid', 'days'], + 'properties' => [ + 'uid' => ['type' => 'number', 'description' => '用户ID'], + 'days' => ['type' => 'integer', 'description' => '赠送天数:-1=永久会员,正整数=增加的天数'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(uid、days) + * @return array 赠送后的会员状态 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $uid = (int)($args['uid'] ?? 0); + if ($uid <= 0) { + throw new McpException('参数错误:缺少 uid 或格式不正确'); + } + $days = $args['days'] ?? null; + if (!is_int($days) && (!is_string($days) || !ctype_digit($days) && (string)$days !== '-1')) { + throw new McpException('days 必须是整数(-1 表示永久,正整数为天数)'); + } + $days = (int)$days; + if ($days === 0 || $days < self::DAYS_FOREVER) { + throw new McpException('days 只能是 -1(永久)或正整数天数,不能为 0 或小于 -1'); + } + + // 服务层对永久会员直接跳过(返回 true 无动作),此处提前识别给出明确结果 + $user = $this->services->getUserInfo($uid, 'uid,is_ever_level,overdue_time,is_money_level'); + if (!$user) { + throw new McpException('用户不存在'); + } + if ((int)$user['is_ever_level'] === 1) { + return [ + 'uid' => $uid, + 'days' => $days, + 'is_ever_level' => 1, + 'overdue_time' => 0, + 'message' => '该用户已是永久付费会员,无需赠送', + ]; + } + + try { + $this->services->saveGiveLevelTime($uid, $days); + } catch (AdminException $e) { + // 「赠送天数不能为0」等业务错误透传 + throw new McpException($e->getMessage()); + } + + // 重新读取会员状态,返回赠送后的到期时间 + $user = $this->services->getUserInfo($uid, 'uid,is_ever_level,overdue_time'); + $overdueTime = (int)($user['overdue_time'] ?? 0); + + return [ + 'uid' => $uid, + 'days' => $days, + 'is_ever_level' => (int)($user['is_ever_level'] ?? 0), + 'overdue_time' => $overdueTime, + 'overdue_date' => $overdueTime > 0 ? date('Y-m-d H:i:s', $overdueTime) : '', + 'message' => $days === self::DAYS_FOREVER + ? '已把该用户设为永久付费会员' + : '已赠送 ' . $days . ' 天付费会员,到期时间 ' . ($overdueTime > 0 ? date('Y-m-d H:i:s', $overdueTime) : '未知'), + ]; + } +} diff --git a/backend/modules/service/mcp/tools/UserMoneyListTool.php b/backend/modules/service/mcp/tools/UserMoneyListTool.php new file mode 100644 index 000000000..c2f3d3a0c --- /dev/null +++ b/backend/modules/service/mcp/tools/UserMoneyListTool.php @@ -0,0 +1,131 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\dao\user\UserMoneyDao; +use crmeb\interfaces\McpToolInterface; + +/** + * MCP 工具:余额流水列表 + * + * v8 起用户余额变动记于独立账单表 user_money,历史数据可能仍存于旧账单表。 + */ +class UserMoneyListTool implements McpToolInterface +{ + /** + * 余额流水 DAO + * @var UserMoneyDao + */ + private UserMoneyDao $dao; + + /** + * 构造函数 + * @param UserMoneyDao $dao 余额流水 DAO + */ + public function __construct(UserMoneyDao $dao) + { + $this->dao = $dao; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_money_list'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '查询用户余额流水列表(v8 独立余额账单表),支持按用户 UID、变动类型、收/支方向筛选,' + . '返回变动金额、变动后余额、标题与备注;' + . 'type 常见值:recharge 充值入账、pay_product 余额支付购买商品、pay_member 余额购买会员、' + . 'offline_scan 线下收银、pay_product_refund 订单退款到余额、recharge_refund 充值退款、' + . 'extract 佣金提现到余额、lottery_add/lottery_use 抽奖、system_add/system_sub 系统调整、register_system_add 注册赠送;' + . 'pm:1 收入、0 支出;status:1 有效、0 待确定、-1 无效。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '余额流水列表'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '财务'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'page' => ['type' => 'number', 'description' => '页码'], + 'limit' => ['type' => 'number', 'description' => '每页数量'], + 'uid' => ['type' => 'number', 'description' => '用户 UID'], + 'type' => ['type' => 'string', 'description' => '变动类型,如 recharge/pay_product/system_add'], + 'pm' => ['type' => 'number', 'description' => '收/支方向:1 收入、0 支出'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(page/limit/uid/type/pm) + * @return array 余额流水列表和总数 + */ + public function execute(array $args): array + { + $page = max(1, (int)($args['page'] ?? 1)); + $limit = min(100, max(1, (int)($args['limit'] ?? 10))); + + $where = []; + if (!empty($args['uid'])) { + $where[] = ['uid', '=', (int)$args['uid']]; + } + if (isset($args['type']) && $args['type'] !== '') { + $where[] = ['type', '=', (string)$args['type']]; + } + if (isset($args['pm']) && $args['pm'] !== '') { + $where[] = ['pm', '=', (int)$args['pm'] == 1 ? 1 : 0]; + } + + $list = $this->dao->search($where) + ->field('id,uid,link_id,type,title,number,balance,pm,mark,status,add_time') + ->order('id desc') + ->page($page, $limit) + ->select() + ->toArray(); + + $count = $this->dao->search($where)->count(); + return ['list' => $list, 'count' => $count]; + } +} diff --git a/backend/modules/service/mcp/tools/UserUpdateTool.php b/backend/modules/service/mcp/tools/UserUpdateTool.php new file mode 100644 index 000000000..a8b1e609e --- /dev/null +++ b/backend/modules/service/mcp/tools/UserUpdateTool.php @@ -0,0 +1,171 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use app\services\user\UserLabelRelationServices; +use app\services\user\UserServices; +use crmeb\exceptions\AdminException; +use crmeb\interfaces\McpToolInterface; +use modules\service\mcp\McpException; + +/** + * MCP 工具:修改用户资料 + * + * 与管理后台「编辑用户」同链路:先读用户当前值合并显式传入的修改项, + * 再调用 UserServices::updateInfo 全量落库;只改传入字段,不传的字段 + * 保持原值,不会清空其他资料。服务层业务校验不因 AI 调用而豁免。 + */ +class UserUpdateTool implements McpToolInterface +{ + /** + * 用户服务 + * @var UserServices + */ + private UserServices $services; + + /** + * 用户标签关联服务(读取当前标签避免被清空) + * @var UserLabelRelationServices + */ + private UserLabelRelationServices $labelServices; + + /** + * 构造函数 + * @param UserServices $services 用户服务 + * @param UserLabelRelationServices $labelServices 用户标签关联服务 + */ + public function __construct(UserServices $services, UserLabelRelationServices $labelServices) + { + $this->services = $services; + $this->labelServices = $labelServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_user_update'; + } + + /** + * 交互声明:修改用户资料会直接变更用户数据,需用户确认后才真实执行 + * @return string execute=需确认后执行 + */ + public function interaction(): string + { + return 'execute'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '修改单个用户的基础资料,等同管理后台「编辑用户」;只修改显式传入的字段,未传字段保持原值,成功返回修改后的资料摘要。' + . '必填参数:uid 用户ID。' + . '可选字段:real_name 真实姓名(必填校验,不能为空、25 字以内)、phone 手机号(必填校验,11 位大陆手机号且全库唯一)、' + . 'email 邮箱(可空,填了须合法)、card_id 身份证号(可空,填了须合法)、birthday 生日(YYYY-MM-DD)、' + . 'addres 详细地址(255 字以内)、status 用户状态(1=开启、0=锁定)、group_id 用户分组 ID(0=未分组)、' + . 'label_ids 用户标签 ID 数组(覆盖语义:传入后用户标签被替换为该数组;只想改资料不要传此参数)、' + . 'mark 备注(255 字以内,仅改备注建议用 crmeb_user_mark_set)。' + . '至少传入一个可选字段。不能通过本工具修改余额、积分、会员等级与密码;改分组用 crmeb_user_group_set,' + . '改标签用 crmeb_user_label_set,赠送等级用 crmeb_user_level_give。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '资料修改'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '用户'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'required' => ['uid'], + 'properties' => [ + 'uid' => ['type' => 'number', 'description' => '用户ID'], + 'real_name' => ['type' => 'string', 'maxLength' => 25, 'description' => '真实姓名,不能为空'], + 'phone' => ['type' => 'string', 'description' => '手机号,11 位大陆手机号,全库唯一'], + 'email' => ['type' => 'string', 'description' => '邮箱,可空'], + 'card_id' => ['type' => 'string', 'description' => '身份证号码,可空'], + 'birthday' => ['type' => 'string', 'description' => '生日,YYYY-MM-DD'], + 'addres' => ['type' => 'string', 'maxLength' => 255, 'description' => '详细地址'], + 'status' => ['type' => 'integer', 'enum' => [0, 1], 'description' => '用户状态:1=开启,0=锁定'], + 'group_id' => ['type' => 'integer', 'minimum' => 0, 'description' => '用户分组 ID,0=未分组'], + 'label_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => '用户标签 ID 数组,覆盖语义,传入后整体替换用户标签'], + 'mark' => ['type' => 'string', 'maxLength' => 255, 'description' => '备注,255 字以内'], + ], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(uid + 至少一个修改字段) + * @return array 修改后的资料摘要 + * @throws McpException 参数非法或服务层业务校验失败 + */ + public function execute(array $args): array + { + $uid = (int)($args['uid'] ?? 0); + if ($uid <= 0) { + throw new McpException('参数错误:缺少 uid 或格式不正确'); + } + + $editable = ['real_name', 'phone', 'email', 'card_id', 'birthday', 'addres', 'mark', 'status', 'group_id', 'label_ids']; + $overrides = array_intersect_key($args, array_flip($editable)); + if (!$overrides) { + throw new McpException('未提供任何要修改的字段,可选字段:real_name、phone、email、card_id、birthday、addres、status、group_id、label_ids、mark'); + } + + $data = UserEditPayload::build($this->services, $this->labelServices, $uid, $overrides); + + try { + $this->services->updateInfo($uid, $data); + } catch (AdminException $e) { + // 服务层业务校验失败:转 McpException 让消息透传给 AI 客户端 + throw new McpException($e->getMessage()); + } + + return [ + 'uid' => $uid, + 'real_name' => $data['real_name'], + 'phone' => $data['phone'], + 'status' => $data['status'], + 'group_id' => $data['group_id'], + 'label_ids' => $data['label_id'], + 'mark' => $data['mark'], + 'message' => '用户资料修改成功,可通过 crmeb_user_detail 查看最新资料', + ]; + } +}