CRMEB/backend/modules/service/mcp/tools/CouponCreateTool.php
sugar1569 a30565b3a0 feat: 新增MCP工具集合,包含用户、财务类业务接口
本次提交新增12个MCP工具,覆盖用户管理与财务统计场景:
- 用户类:用户标签列表、标签创建/删除/设置、分组设置、会员等级/时长赠送、用户资料/备注修改
- 财务类:充值记录/统计、余额流水、佣金流水、提现申请/统计、发票记录
所有工具均实现完整的入参校验、业务逻辑与脱敏处理,符合管理后台同链路操作逻辑。
2026-09-24 11:58:20 +08:00

413 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
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;
}
}