Merge branch 'develop'

This commit is contained in:
mzl
2025-07-29 09:00:04 +08:00
parent a551507e4d
commit d74b374198
106 changed files with 6754 additions and 1346 deletions
+1
View File
@@ -6,3 +6,4 @@
.env
/tests/tmp
/tests/.phpunit.result.cache
/public/uploads
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\admin\service\CorpService;
class CorpController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new CorpService();
}
protected function indexFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
return $data;
}
/**
* 如果想对某些接口做额外校验或改写,
* 也可以在这里覆盖 trait 提供的方法:
*
* public function store(Request $request)
* {
* // 自定义验证
* $this->validate(...);
* // 然后再调用标准逻辑
* return parent::store($request);
* }
*/
}
+102 -83
View File
@@ -2,97 +2,116 @@
namespace app\admin\controller;
use app\common\validate\RoleValidate;
use app\common\service\RoleService;
use support\Request;
use app\common\controller\BaseController;
use app\admin\service\RoleService;
use app\common\traits\crud\CrudControllerTrait;
class RoleController extends \app\common\controller\BaseController
class RoleController extends BaseController
{
/**
* 获取角色列表
*/
public function list(Request $request)
use CrudControllerTrait;
protected $service;
public function __construct()
{
try {
$params = $request->all();
$result = (new RoleService())->getRoleList($params);
// 定义后处理器
$postProcessor = function($formattedData) {
if (isset($formattedData['created_at']))
$formattedData['created_at'] = date('Y-m-d H:i:s', $formattedData['created_at']);
if (isset($formattedData['updated_at']))
$formattedData['updated_at'] = date('Y-m-d H:i:s', $formattedData['updated_at']);
return $formattedData;
};
return $this->success($this->transformData($result, $postProcessor), '角色列表获取成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
$this->service = new RoleService();
}
/**
* 获取角色详情
*/
public function detail(Request $request)
protected function indexFormat($data)
{
try {
$roleId = $request->get('role_id');
if (!$roleId) {
return $this->error('缺少角色ID', 422);
}
$result = (new RoleService())->getRoleDetail($roleId);
return $this->success($result, '角色详情获取成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
return $data;
}
/**
* 增加角色
*/
public function create(Request $request)
{
try {
$data = $this->validate(new RoleValidate(), $request->post(), 'role_create');
$result = (new RoleService())->createRole($data);
return $this->success($result, '角色创建成功');
} catch (\think\exception\ValidateException $e) {
return $this->error($e->getError(), 422);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
// /**
// * 获取角色列表
// */
// public function list(Request $request)
// {
// try {
// $params = $request->all();
// $result = (new RoleService())->getRoleList($params);
// // 定义后处理器
// $postProcessor = function($formattedData) {
// if (isset($formattedData['created_at']))
// $formattedData['created_at'] = date('Y-m-d H:i:s', $formattedData['created_at']);
// if (isset($formattedData['updated_at']))
// $formattedData['updated_at'] = date('Y-m-d H:i:s', $formattedData['updated_at']);
// return $formattedData;
// };
// return $this->success($this->transformData($result, $postProcessor), '角色列表获取成功');
// } catch (\Throwable $e) {
// return $this->error($e->getMessage());
// }
// }
/**
* 修改角色
*/
public function update(Request $request)
{
try {
$data = $this->validate(new RoleValidate(), $request->post(), 'role_update');
$result = (new RoleService())->updateRole($data);
return $this->success($result, '角色更新成功');
} catch (\think\exception\ValidateException $e) {
return $this->error($e->getError(), 422);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
// /**
// * 获取角色详情
// */
// public function detail(Request $request)
// {
// try {
// $roleId = $request->get('role_id');
// if (!$roleId) {
// return $this->error('缺少角色ID', 422);
// }
// $result = (new RoleService())->getRoleDetail($roleId);
// return $this->success($result, '角色详情获取成功');
// } catch (\Throwable $e) {
// return $this->error($e->getMessage());
// }
// }
/**
* 删除角色
*/
public function delete(Request $request)
{
try {
$roleId = $request->get('role_id');
if (!$roleId) {
return $this->error('缺少角色ID', 422);
}
(new RoleService())->deleteRole($roleId);
return $this->success([], '角色删除成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
// /**
// * 增加角色
// */
// public function create(Request $request)
// {
// try {
// $data = $this->validate(new RoleValidate(), $request->post(), 'role_create');
// $result = (new RoleService())->createRole($data);
// return $this->success($result, '角色创建成功');
// } catch (\think\exception\ValidateException $e) {
// return $this->error($e->getError(), 422);
// } catch (\Throwable $e) {
// return $this->error($e->getMessage());
// }
// }
// /**
// * 修改角色
// */
// public function update(Request $request)
// {
// try {
// $data = $this->validate(new RoleValidate(), $request->post(), 'role_update');
// $result = (new RoleService())->updateRole($data);
// return $this->success($result, '角色更新成功');
// } catch (\think\exception\ValidateException $e) {
// return $this->error($e->getError(), 422);
// } catch (\Throwable $e) {
// return $this->error($e->getMessage());
// }
// }
// /**
// * 删除角色
// */
// public function delete(Request $request)
// {
// try {
// $roleId = $request->get('role_id');
// if (!$roleId) {
// return $this->error('缺少角色ID', 422);
// }
// (new RoleService())->deleteRole($roleId);
// return $this->success([], '角色删除成功');
// } catch (\Throwable $e) {
// return $this->error($e->getMessage());
// }
// }
}
@@ -0,0 +1,33 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\admin\service\SystemsService;
class SystemsController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new SystemsService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace app\admin\service;
use app\common\validate\CorpValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\Corp;
class CorpService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Corp::class;
/**
* 验证器
*/
protected $validator = CorpValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 如果需要自定义,比如强制加一个 where['status']=1,
* 只要在这里重写:
*/
// protected function buildQueryConfig(Request $request): array
// {
// $config = [];
// $config['where']['id'] = 2;
// return $config;
// }
// /**
// * 复杂查询示例:
// * 只要在 URL 里添加 ?complex_query=1,
// * 并传入你需要的任何参数,
// * `serviceList()` 就会走到这里来:
// */
// protected function complexQuery(Request $request): array
// {
// // 假设前端传了 date_from、date_to
// $from = $request->get('date_from');
// $to = $request->get('date_to');
// // 交给 CrudUtil 执行一个自定义 query
// $pager = $this->logicComplex(function($q) use ($from, $to) {
// $q->where('status', 1);
// if ($from && $to) {
// $q->whereBetween('created_at', [$from, $to]);
// }
// // 再加个排序
// $q->orderBy('created_at','desc');
// // 用 paginate 而不是 listAll
// return $q->paginate(
// /* perPage */ (int)request()->get('limit',15),
// ['*'],
// 'page',
// (int)request()->get('page',1)
// );
// });
// // 把 Paginator 转成数组格式
// return [
// 'total' => $pager->total(),
// 'data' => $pager->items(),
// ];
// }
// protected function afterServiceList(array $res): array
// {
// // 对列表的 data 做处理
// $res['data'] = array_map(fn($i)=>[...], $res['data']);
// return $res;
// }
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace app\admin\service;
use app\common\validate\RoleValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\Role;
class RoleService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Role::class;
/**
* 验证器
*/
protected $validator = RoleValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace app\admin\service;
use app\common\validate\SystemsValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\Systems;
class SystemsService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Systems::class;
/**
* 验证器
*/
protected $validator = SystemsValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace app\command;
use app\common\model\InquiryLog;
use app\common\model\User;
use app\common\model\UserSn;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
class DomainCommand extends Command
{
protected static $defaultName = 'domain';
protected static $defaultDescription = '';
protected function execute(InputInterface $input, OutputInterface $output): int
{
$log = new InquiryLog();
$list = $log->orderBy('id', 'desc')->get();
foreach ($list as $item) {
if (!$item->content) {
continue;
}
$item->content = processRichTextImagesEnhanced($item->content);
$item->save();
$output->writeln("处理了{$item->id} \n");
}
return self::SUCCESS;
}
}
/**
* 增强版 - 处理富文本内容中的图片URL
*
* @param string $content 富文本HTML内容
* @param string $newDomain 新域名(如: https://fdesk.hnxinyouhe.com)
* @param string $oldDomain 旧域名(如: http://219.157.255.213:25326)
* @return string 处理后的富文本内容
*/
function processRichTextImagesEnhanced($content, $newDomain = 'https://fdesk.hnxinyouhe.com', $oldDomain = 'http://219.157.255.213:25326') {
// 标准化域名格式(去除末尾斜杠)
$newDomain = rtrim($newDomain, '/');
$oldDomain = rtrim($oldDomain, '/');
// 要处理的属性列表
$attributes = ['src', 'data-href', 'href', 'data-src', 'data-original'];
foreach ($attributes as $attr) {
// 情况1:替换旧域名为新域名
$content = preg_replace(
'/('.$attr.'=["\'])'.preg_quote($oldDomain, '/').'(\/uploads\/)/i',
'$1' . $newDomain . '$2',
$content
);
// 情况2:给无域名的路径添加新域名
$content = preg_replace(
'/('.$attr.'=["\'])(\/uploads\/)/i',
'$1' . $newDomain . '$2',
$content
);
// 情况3:处理可能存在的其他域名(可选)
$content = preg_replace(
'/('.$attr.'=["\'])(https?:\/\/[^\/]+)(\/uploads\/)/i',
'$1' . $newDomain . '$3',
$content
);
}
return $content;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace app\command;
use app\common\model\User;
use app\common\model\UserSn;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
class TbCommand extends Command
{
protected static $defaultName = 'tb-db';
protected static $defaultDescription = '';
protected function execute(InputInterface $input, OutputInterface $output): int
{
$user = new User();
$list = $user->all();
foreach ($list as $item) {
// UserSn::where('user_sn', $item->sn)->update(['user_id' => $item->id]);
}
return self::SUCCESS;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ class AuthController extends BaseController
/**
* 不需要头信息的方法
*/
protected $noNeedHeader = ['feishuAuth'];
protected $noNeedHeaderData = ['feishuAuth'];
protected $noNeedLogin = ['feishuScope', 'login', 'register', 'feishuAuth'];
+44
View File
@@ -10,10 +10,54 @@ use support\Request;
class BaseController
{
/**
* 响应处理、数据格式化处理
*/
use ResponseTrait, DataTransformer;
/**
* 验证器
* @var Validate
*/
protected mixed $validator = null;
/**
* @var Model
*/
protected $model = null;
/**
* 不登录不鉴权
* @var array
*/
protected $noNeedLogin = [];
/**
* 登录不鉴权
* @var array
*/
protected $noNeedAuth = [];
/**
* 不用头部数据
* @var array
*/
protected $noNeedHeaderData = [];
/**
* 数据限制
* null 不做限制,任何管理员都可以查看该表的所有数据
* auth 管理员能看到自己以及自己的子管理员插入的数据
* personal 管理员只能看到自己插入的数据
* @var string
*/
protected $dataLimit = null;
/**
* 数据限制字段
*/
protected $dataLimitField = 'admin_id';
/**
* 验证数据
*
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace app\common\controller;
use support\Request;
use support\Response;
use app\common\utils\FeishuMessage;
use app\inquiry\service\InquiryLogService;
use app\common\model\CrmCustomer;
use app\common\model\InquiryLog;
use app\common\model\User;
use support\exception\BusinessException;
class NotifyController
{
/**
* 不用头部数据
* @var array
*/
protected $noNeedHeaderData = ['feishuCallback', 'feishuEvent'];
/**
* 不登录不鉴权
* @var array
*/
protected $noNeedLogin = ['feishuCallback', 'feishuEvent'];
/**
* 飞书回调
* @param Request $request
* @return Response
*/
public function feishuCallback(Request $request)
{
try {
$param = $request->post();
// 解析数据
// $param = json_decode($param['event'], true);
$orderSn = $param['event']['action']['value'];
if (strpos($orderSn, '"') !== false) {
$orderSn = str_replace('"', '', $orderSn);
}
// 处理询单&客户线索信息
$openId = $param['event']['operator']['open_id'];
// 获取询单
$order = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('order_no', $orderSn)->first();
if (!$order) {
return $this->error('未找到询单记录');
}
// 获取客户xians
$customer = CrmCustomer::where('sn', $orderSn)->first();
if (!$customer) {
return $this->error('未找到客户线索');
}
// 获取用户
$uinfo = User::where('open_id', $openId)->first();
// 更新客户线索
$customer->user_id = $uinfo->id;
// 更新询单数据
$order->recipient = $uinfo->id;
$order->status = 2;
$order->t_time = time();
// 保存
if (!$order->save() || !$customer->save()) {
return $this->error('跟踪失败');
}
// 获取飞书消息模板
$feishuMessage = new FeishuMessage();
$content = $feishuMessage->groupUpdateTemplates(
(new InquiryLogService())->qualityName($order->quality),
$order->order_no,
$order->channelInfo ? $order->channelInfo->name : '',
$order->countryInfo ? $order->countryInfo->cn_addr : '',
$order->enter ? $order->enter->name : '',
$order->r_time ? date('Y-m-d H:i:s', $order->r_time) : '',
$order->t_time ? date('Y-m-d H:i:s', $order->t_time) : ''
);
// 组装飞书消息模板
$data = '{
"toast": {
"type": "success",
"content": "操作成功"
},
"card": {
"type": "raw",
"data": '.$content.'
}
}';
// 响应消息
return new Response(200, ['Content-Type' => 'application/json'], $data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function error($msg = '')
{
$data = '{
"toast": {
"type": "error",
"content": "'.$msg.'"
}
}';
return new Response(200, ['Content-Type' => 'application/json'], $data);
}
/**
* 飞书事件
* @param Request $request
* @return Response
*/
public function feishuEvent(Request $request)
{
try {
$data = $request->post();
var_dump('飞书事件回调:'.$data);
return json($data);
} catch (\Throwable $e) {
return json($e->getMessage());
}
}
}
+30 -2
View File
@@ -5,11 +5,15 @@ namespace app\common\controller;
use support\Request;
use app\common\service\CorpService;
use app\common\service\CorpFeishuAppService;
use app\common\service\SystemsService;
use app\common\service\UserService;
class PresetController extends BaseController
{
protected $noNeedLogin = ['corps', 'feishuApp'];
protected $noNeedLogin = ['corps', 'feishuApp', 'systems'];
protected $noNeedHeaderData = ['corps', 'feishuApp', 'systems'];
public function corps()
{
@@ -27,10 +31,34 @@ class PresetController extends BaseController
{
try {
$corpId = $request->get('corp_id');
$result = (new CorpFeishuAppService())->getFeishuApp($corpId);
$systemId = $request->get('system_id');
$result = (new CorpFeishuAppService())->getFeishuApp($corpId, $systemId);
return $this->success($result, '获取飞书应用信息');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function systems()
{
try {
$result = (new SystemsService())->getList();
return $this->success($result, '获取系统列表');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function users()
{
try {
$result = (new UserService())->getList();
return $this->success($result, '获取用户列表');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
@@ -12,6 +12,10 @@ class UploadController
use ResponseTrait;
protected $noNeedLogin = ['proxy'];
protected $noNeedHeaderData = ['proxy'];
public function upload(Request $request)
{
try {
+55 -1
View File
@@ -15,11 +15,65 @@ class UserController extends BaseController
{
try {
$uid = $request->user->uid;
$result = (new UserService())->getUserInfo($uid);
$systemCode = $request->system_code;
$result = (new UserService())->getUserInfo($uid, $systemCode);
return $this->success($result, '获取用户信息');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 用户信息
*/
public function getUser(Request $request)
{
try {
$uid = $request->user->uid;
$corp_abbr = $request->basic_data['corp_abbr'];
$result = (new UserService())->getUser($uid,$corp_abbr);
return $this->success($result, '获取用户信息');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 编辑用户
*/
public function edit(Request $request)
{
try {
$uid = $request->user->uid;
$data = $request->all();
$result = (new UserService())->edit($uid,$data);
unset($result['password']);
unset($result['union_id']);
unset($result['open_id']);
unset($result['tenant_key']);
return $this->success($result, '编辑成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 修改密码
* @param Request $request
* @return \support\Response
* @throws \Random\RandomException
*/
public function savePassword(Request $request)
{
try {
$uid = $request->user->uid;
$data = $request->all();
$result = (new UserService())->savePassword($uid,$data);;
return $this->success($result, '编辑成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
+61 -5
View File
@@ -4,6 +4,7 @@ namespace app\common\logic;
use app\common\model\Corp;
use app\common\model\CorpFeishuApp;
use app\common\model\Resource;
use app\common\model\UserTokenLog;
use app\common\model\Systems;
use app\common\model\User;
@@ -85,16 +86,23 @@ class AuthLogic
{
try {
$phone = $this->sanitizePhone($feishuUserInfo['mobile'] ?? '');
$user = User::where('phone', $phone)->where('corp_id', $feishuUserInfo['corp_id'])->first();
$email = $feishuUserInfo['enterprise_email'];
$user = User::where('corp_id', $feishuUserInfo['corp_id'])
->where(function($query) use ($phone, $email) {
$query->where('phone', $phone)
->orWhere('email', $email);
})
->first();
if (!$user) {
// 获取编号
$userSn = $this->getAvailableUserSn();
// 创建用户
$user = User::create([
'name' => $feishuUserInfo['en_name'],
'name' => $feishuUserInfo['name'],
'avatar' => $feishuUserInfo['avatar_middle'],
'phone' => $this->sanitizePhone($feishuUserInfo['mobile'] ?? ''),
'email' => $feishuUserInfo['enterprise_email'],
'union_id' => $feishuUserInfo['union_id'],
'user_id' => $feishuUserInfo['user_id'],
'open_id' => $feishuUserInfo['open_id'],
@@ -111,12 +119,20 @@ class AuthLogic
// 是否登陆
$user->isLogin = false;
} else {
$user->update([
$updated = [
'union_id' => $feishuUserInfo['union_id'],
'user_id' => $feishuUserInfo['user_id'],
'open_id' => $feishuUserInfo['open_id'],
'tenant_key' => $feishuUserInfo['tenant_key']
]);
];
if ($phone && $phone == $user->phone) {
$updated['phone'] = $phone;
}
if ($phone && $phone == $user->phone) {
$updated['email'] = $email;
}
// 更新飞书信息
$user->update($updated);
}
return $user;
} catch (\Throwable $e) {
@@ -140,6 +156,7 @@ class AuthLogic
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
$homeUrl = $this->getHome($system->id);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
@@ -150,7 +167,8 @@ class AuthLogic
'expires_in' => config('jwt.jwt_expiration'),
'system_code' => $data['system_code'],
'corp_abbr' => $data['corp_abbr'],
'login_type' => $data['login_type']
'login_type' => $data['login_type'],
'home_url' => $homeUrl,
// 'payload' => $payload,
];
}
@@ -500,4 +518,42 @@ class AuthLogic
Redis::del("user_token_meta:{$token}");
UserTokenLog::where('token', $token)->update(['is_valid'=>0]);
}
public function getHome($systemId)
{
$info = Resource::where('system_id', $systemId)
->where('is_home', 1)
->first();
if (!$info) {
return '/dashboard/console';
}
// 递归拼接 URL
$url = $this->buildParentUrl($info);
return $url ?: '/dashboard/console';
}
/**
* 递归构建父级 URL
* @param Resource $resource
* @return string
*/
protected function buildParentUrl(Resource $resource)
{
$url = $resource->url ?? '';
// 如果存在父级,则递归拼接
if ($resource->parent_id != 0) {
$parent = Resource::find($resource->parent_id);
if ($parent) {
$parentUrl = $this->buildParentUrl($parent);
$url = $parentUrl . ($url ? '/' . ltrim($url, '/') : '');
}
}
return $url;
}
}
-593
View File
@@ -1,593 +0,0 @@
<?php
namespace app\common\logic;
use app\common\model\Corp;
use app\common\model\CorpFeishuApp;
use app\common\model\UserTokenLog;
use app\common\model\Systems;
use app\common\model\User;
use app\common\model\UserFeishuApp;
use app\common\model\UserSn;
use app\common\utils\Feishu;
use app\common\utils\Jwt;
use support\Redis;
use support\exception\BusinessException;
use support\Log;
class AuthLogic_bak
{
/**
* 账号密码登录
*/
public function uPwdLogin(array $data): array
{
$user = $this->validateUser($data['account'], $data['password']);
$system = $this->validateSystem($data['system_code']);
$this->validateSystemPermission($user, $system);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 飞书登录
* 如果用户不存在则新增用户信息
*/
public function feishuLogin(array $data, bool $isLogin = true): array
{
$feishuUserInfo = $this->getFeishuUserInfo($data);
$user = $this->getOrCreateUser($feishuUserInfo);
$system = $this->validateSystem($data['system_code']);
if ($user->isLogin === true) {
$this->validateSystemPermission($user, $system);
}
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 扫码登录
*/
public function qrCodeLogin(array $data): array
{
$userId = $this->validateQrCodeToken($data['qr_code_token']);
$user = User::find($userId);
if (!$user || $user->status != 1) {
throw new BusinessException('用户无效', 403);
}
$system = $this->validateSystem($data['system_code']);
$this->validateSystemPermission($user, $system);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 注册
*/
public function register(array $data)
{
// 获取可用用户编号
$sn = $this->getAvailableUserSn();
$data['sn'] = $sn->user_sn;
// 对密码进行哈希处理
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
// 获取企业ID
$corpInfo = $this->validateCorp($data['corp_abbr']);
$data['corp_id'] = $corpInfo->id;
// 创建用户记录
$user = $this->createUser($data);
// 更新用户编号的用户 ID
$this->updateUserId($user->id, $sn->user_sn);
// 注册后如果直接登录(返回token)
$system = $this->validateSystem($data['system_code']);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
$this->recordUserToken($user->id, $token, $data['system_code']);
return [
'user' => $user,
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
public function updateUserId($userId, $userSn)
{
$updated = UserSn::where('user_sn', $userSn)
->update(['user_id' => $userId]);
if ($updated === 0) {
throw new BusinessException('用户编号不存在或更新失败');
}
return $updated;
}
/**
* 获取可用的用户编号
*
* @return \app\common\model\UserSn 用户编号模型实例
* @throws BusinessException 当未找到适用编号时抛出
*/
private function getAvailableUserSn(): \app\common\model\UserSn
{
$userSnModel = new UserSn();
$sn = $userSnModel->where('user_id', '=', 0)->inRandomOrder()->first();
if (!$sn) {
Log::error('用户注册时未找到适用编号');
throw new BusinessException('未找到适用编号');
}
return $sn;
}
/**
* 创建用户记录
*
* @param array $data 包含用户信息的数组
* @return \app\common\model\User 用户模型实例
*/
private function createUser(array $data): \app\common\model\User
{
try {
return User::create($data);
} catch (\Throwable $e) {
Log::error('用户注册时创建用户记录失败', [
'data' => $data,
'error' => $e->getMessage()
]);
throw new BusinessException('用户注册失败,请稍后重试', 500);
}
}
/**
* 刷新token
* @param string $refreshToken
* @param string|null $systemCode 可选,指定系统
* @return array
*/
public function refreshToken(string $refreshToken, ?string $systemCode = null): array
{
if (!$systemCode) throw new BusinessException('缺少system_code', 422);
// 校验refresh_token
$userId = Redis::get("refresh_token_map:{$refreshToken}:{$systemCode}");
if (!$userId) {
throw new BusinessException('refresh_token无效或已过期', 401);
}
$validToken = Redis::get("refresh_token:{$userId}:{$systemCode}");
if ($validToken !== $refreshToken) {
throw new BusinessException('refresh_token无效或已过期', 401);
}
$user = User::find($userId);
if (!$user || $user->status != 1) {
throw new BusinessException('用户无效', 403);
}
$system = $this->validateSystem($systemCode);
$this->validateSystemPermission($user, $system);
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$this->recordUserToken($user->id, $token, $systemCode);
return [
'access_token' => $token,
'expires_in' => config('jwt.jwt_expiration'),
'payload' => $payload,
];
}
/**
* 记录用户token到 Redis 和 MySQL
*/
protected function recordUserToken(int $userId, string $token, string $systemCode)
{
$request = request();
$ip = $request ? $request->getRealIp() : '';
$ua = $request ? $request->header('user-agent', '') : '';
$now = time();
$expireAt = $now + config('jwt.jwt_expiration');
// Redis Set 用户token集合
Redis::sAdd("user_tokens:{$userId}:{$systemCode}", $token);
Redis::expire("user_tokens:{$userId}:{$systemCode}", config('jwt.jwt_expiration'));
// Redis Hash token元信息
$meta = [
'system_code' => $systemCode,
'login_ip' => $ip,
'user_agent' => $ua,
'login_time' => $now,
'expired_at' => $expireAt,
];
Redis::hMSet("user_token_meta:{$token}", $meta);
Redis::expire("user_token_meta:{$token}", config('jwt.jwt_expiration'));
// 可选:MySQL 持久化
try {
UserTokenLog::create([
'user_id' => $userId,
'token' => $token,
'system_code'=> $systemCode,
'login_ip' => $ip,
'user_agent' => $ua,
'login_time' => $now,
'expired_at' => $expireAt,
'is_valid' => 1,
]);
} catch (\Throwable $e) {
Log::error('Token log write failed: '.$e->getMessage());
}
}
/**
* 校验token有效性
*/
public function verifyToken(string $token, string $systemCode): array
{
if (!$systemCode) throw new BusinessException('缺少system_code', 422);
$payload = (new Jwt())->verifyToken($token);
// 校验黑名单
if (Redis::has("token_blacklist:{$token}")) {
throw new BusinessException('token已失效', 403);
}
// 强制校验aud(系统隔离)
if ($systemCode && $payload['aud'] != $systemCode && $payload['aud'] != ($payload['system_code'] ?? '')) {
throw new BusinessException('token不属于当前系统', 403);
}
return (array)$payload;
}
/**
* 登出/失效token
*/
public function logout(?string $token, ?string $systemCode = null)
{
if ($token) {
Redis::set("token_blacklist:{$token}", 1, config('jwt.jwt_expiration'));
$payload = null;
try { $payload = (new Jwt())->verifyToken($token); } catch (\Throwable $e) {}
$userId = $payload['uid'] ?? null;
$aud = $payload['aud'] ?? null;
$sc = $systemCode ?? $aud;
if ($userId && $sc) {
Redis::sRem("user_tokens:{$userId}:{$sc}", $token);
}
Redis::del("user_token_meta:{$token}");
UserTokenLog::where('token', $token)->update(['is_valid'=>0]);
}
}
/**
* 强制拉黑token
*/
public function blockToken(string $token, ?string $systemCode = null)
{
if (!$token) {
throw new BusinessException('token不能为空', 422);
}
Redis::set("token_blacklist:{$token}", 1, config('jwt.jwt_expiration'));
$payload = null;
try { $payload = (new Jwt())->verifyToken($token); } catch (\Throwable $e) {}
$userId = $payload['uid'] ?? null;
$aud = $payload['aud'] ?? null;
$sc = $systemCode ?? $aud;
if ($userId && $sc) {
Redis::sRem("user_tokens:{$userId}:{$sc}", $token);
}
Redis::del("user_token_meta:{$token}");
UserTokenLog::where('token', $token)->update(['is_valid'=>0]);
}
/**
* 获取用户所有活跃token及元信息(按system_code)
*/
public function getUserTokens(int $userId, string $systemCode): array
{
$tokens = Redis::sMembers("user_tokens:{$userId}:{$systemCode}") ?: [];
$result = [];
foreach ($tokens as $token) {
$meta = Redis::hGetAll("user_token_meta:{$token}");
$result[] = [
'token' => $token,
'meta' => $meta,
];
}
return $result;
}
/**
* 强制某用户所有token全部下线
*/
public function blockAllTokensOfUser(int $userId, string $systemCode)
{
$tokens = Redis::sMembers("user_tokens:{$userId}:{$systemCode}") ?: [];
foreach ($tokens as $token) {
$this->blockToken($token, $systemCode);
}
}
/** ----------- 内部方法 ------------- **/
/**
* 用户验证
*/
private function validateUser(string $account, string $password)
{
$user = User::byAccount($account)->first();
if (!$user || !password_verify($password, $user->password)) {
throw new BusinessException('账号或密码错误', 401);
}
if ($user->status != 1) {
throw new BusinessException('账号已禁用', 403);
}
return $user;
}
/**
* 系统验证
*/
private function validateSystem(string $systemCode)
{
$system = Systems::where('code', $systemCode)->first();
if (!$system) {
throw new BusinessException('系统不存在', 403);
}
return $system;
}
/**
* 公司验证
*/
private function validateCorp(string $corpAbbr)
{
$corp = Corp::where('abbr', $corpAbbr)->first();
if (!$corp) {
throw new BusinessException('公司不存在', 403);
}
return $corp;
}
/**
* 飞书应用信息
*/
private function validateFeishuApp(int $corpId, string $systemCode)
{
$feishuAppInfo = CorpFeishuApp::where('corp_id', $corpId)->where('system_code', $systemCode)->first();
if (!$feishuAppInfo) {
throw new BusinessException('飞书应用不存在', 403);
}
return $feishuAppInfo;
}
/**
* 用户系统权限校验
*/
private function validateSystemPermission(User $user, Systems $system)
{
if ($system->is_role) {
$systemIds = $user->systems->pluck('id')->toArray();
if (!in_array($system->id, $systemIds)) {
throw new BusinessException('无该系统权限', 403);
}
}
}
/**
* JWT payload 构建
* 可扩展 payload 字段
*/
private function buildJwtPayload(User $user, ?Systems $system = null): array
{
$now = time();
$exp = $now + config('jwt.jwt_expiration');
$system_ids = $user->systems ? $user->systems->pluck('id')->toArray() : [];
$role_ids = $user->roles ? $user->roles->pluck('id')->toArray() : [];
return [
'sub' => 'user_' . $user->id,
'aud' => $system->code,
'iat' => $now,
'nbf' => $now,
'exp' => $exp,
'typ' => 'access',
'uid' => $user->id,
'role_ids' => $role_ids,
'system_ids' => $system_ids
];
}
/**
* JWT签发
*/
private function generateAccessToken(array $payload)
{
return (new Jwt())->generateToken($payload);
}
/**
* 刷新令牌生成
*/
private function generateRefreshToken(int $userId, string $systemCode)
{
$refreshToken = bin2hex(random_bytes(32));
// user_id+system_code只允许一个refresh_token生效
Redis::set("refresh_token:{$userId}:{$systemCode}", $refreshToken, config('jwt.jwt_refresh_token_expiration'));
// 建立refresh_token到user_id+system_code映射,便于校验
Redis::set("refresh_token_map:{$refreshToken}:{$systemCode}", $userId, config('jwt.jwt_refresh_token_expiration'));
return $refreshToken;
}
/**
* 验证扫码令牌
*/
private function validateQrCodeToken(string $qrCodeToken)
{
$userId = Redis::get("qr_code_token:{$qrCodeToken}");
if (!$userId) {
throw new BusinessException('扫码令牌无效或已过期', 401);
}
return $userId;
}
/**
* 获取飞书用户信息
*/
private function getFeishuUserInfo(array $data)
{
// 获取凭证
$certificate = $this->feishuAppInfo($data['corp_abbr'], $data['system_code']);
// 获取token
$feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], $certificate['abbr']);
$userAccessToken = $feishu->userAccessToken($data['feishu_code'], $certificate['r_url']);
// 获取用户信息
$userInfo = $feishu->userInfo($userAccessToken['access_token']);
if ($userInfo['code'] != 0) {
throw new BusinessException('授权失败,请重试');
}
return $userInfo['data'] + $data + $certificate;
}
/**
* 查找或创建飞书用户
*/
private function getOrCreateUser(array $feishuUserInfo)
{
$userFeishuApp = UserFeishuApp::firstOrCreate([
'name' => $feishuUserInfo['en_name'],
'mobile' => $this->sanitizePhone($feishuUserInfo['mobile'] ?? ''),
'union_id' => $feishuUserInfo['union_id'],
'user_id' => $feishuUserInfo['user_id'],
'open_id' => $feishuUserInfo['open_id'],
'tenant_key' => $feishuUserInfo['tenant_key']
]);
if ($userFeishuApp->uid) {
$user = User::find($userFeishuApp->uid);
$user->isLogin = true;
return $user;
} else {
$user = User::where('phone', $userFeishuApp->mobile)->first();
$user->isLogin = true;
if (!$user) {
$user = $this->createUserByFeishu($feishuUserInfo, $userFeishuApp);
$user->isLogin = false;
}
$userFeishuApp->uid = $user->id;
$userFeishuApp->corp_id = $feishuUserInfo['corp_id'];
if (!$userFeishuApp->save()) {
throw new BusinessException('绑定用户飞书记录失败');
}
return $user;
}
}
/**
* 过滤和格式化手机号
*/
private function sanitizePhone($mobile)
{
return isset($mobile) ? str_replace('+86', '', $mobile) : '';
}
/**
* 创建飞书用户
*/
private function createUserByFeishu(array $feishuUserInfo, UserFeishuApp $userFeishuApp)
{
// 1. 获取适用的用户编号
$userSn = UserSn::where('user_id', '=', 0)->inRandomOrder()->first();
if (!$userSn) {
throw new BusinessException('未找到适用编号');
}
// 2. 创建用户
$user = new User();
$user->sn = $userSn->user_sn;
$user->name = $feishuUserInfo['en_name'];
$user->avatar = $feishuUserInfo['avatar_middle'] ?? '';
$user->password = password_hash($userSn->user_sn, PASSWORD_DEFAULT);
$user->phone = $userFeishuApp->mobile;
$user->email = '';
$user->system_code = $feishuUserInfo['system_code'];
$user->corp_id = $feishuUserInfo['corp_id'];
if (!$user->save()) {
throw new BusinessException('用户创建失败');
}
// 更新使用状态
$this->updateUserId($user->id, $userSn->user_sn);
return $user;
}
/**
* 飞书应用信息
*/
public function feishuAppInfo(string $corpAbbr, string $systemCode)
{
$corpInfo = $this->validateCorp($corpAbbr);
$fsAppInfo = $this->validateFeishuApp($corpInfo->id, $systemCode);
return [
'app_id' => $fsAppInfo->app_id,
'app_secret' => $fsAppInfo->app_secret,
'r_url' => $fsAppInfo->r_url,
'scope' => $fsAppInfo->scopes,
'state' => $fsAppInfo->state,
'abbr' => $corpAbbr,
'corp_id' => $corpInfo->id
];
}
}
-86
View File
@@ -1,86 +0,0 @@
<?php
namespace app\common\logic;
use Illuminate\Database\Eloquent\Model;
use support\exception\BusinessException;
class BaseLogic
{
/**
* 获取列表
*/
public function getList(array $params, string $modelClass)
{
$query = $modelClass::query();
// 你可以在此处添加分页、排序等功能
if (isset($params['page']) && isset($params['per_page'])) {
$query->paginate($params['per_page'], ['*'], 'page', $params['page']);
}
return $query->get();
}
/**
* 获取单个记录
*/
public function getDetail(int $id, string $modelClass)
{
$record = $modelClass::find($id);
if (!$record) {
throw new BusinessException("记录不存在", 404);
}
return $record;
}
/**
* 创建记录
*/
public function create(array $data, string $modelClass)
{
$model = new $modelClass;
$model->fill($data); // 根据字段填充数据
try {
$model->save();
} catch (\Throwable $e) {
throw new BusinessException("创建记录失败: " . $e->getMessage(), 500);
}
return $model;
}
/**
* 更新记录
*/
public function update(array $data, string $modelClass)
{
$record = $modelClass::find($data['id']);
if (!$record) {
throw new BusinessException("记录不存在", 404);
}
$record->update($data);
return $record;
}
/**
* 删除记录
*/
public function delete(int $id, string $modelClass)
{
$record = $modelClass::find($id);
if (!$record) {
throw new BusinessException("记录不存在", 404);
}
try {
$record->delete();
} catch (\Throwable $e) {
throw new BusinessException("删除记录失败: " . $e->getMessage(), 500);
}
return true;
}
}
+2 -2
View File
@@ -8,9 +8,9 @@ class CorpFeishuAppLogic
{
public function getFeishuApp($corpId)
public function getFeishuApp($corpId, $systemId)
{
return CorpFeishuApp::where('corp_id', $corpId)->first(['id', 'corp_id', 'name', 'app_id', 'r_url', 'state']);
return CorpFeishuApp::where('corp_id', $corpId)->where('system_id', $systemId)->first(['id', 'corp_id', 'name', 'app_id', 'r_url', 'state']);
}
}
+7
View File
@@ -2,10 +2,17 @@
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\Corp;
class CorpLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Corp::class;
public function getList()
{
+162 -14
View File
@@ -4,14 +4,17 @@ namespace app\common\logic;
use app\common\model\User;
use app\common\model\Resource;
use app\common\model\Systems;
class PermLogic
{
/**
* 获取用户所有资源(合并角色+直接分配),并加入公共菜单,排序按sort字段
*/
public function getUserResources(int $userId)
public function getUserResources(int $userId, string $systemCode)
{
$system = Systems::where('code', $systemCode)->first();
// 获取用户及其关联角色资源和直接分配的资源
$user = User::with(['roles.resources', 'resources'])->find($userId);
@@ -24,9 +27,10 @@ class PermLogic
// 合并角色资源,排除不需要权限认证的资源
foreach ($user->roles as $role) {
$all = $all->merge(
$role->resources->filter(function ($resource) {
$role->resources->filter(function ($resource) use($system) {
// var_dump($resource->toArray());
// 只保留需要权限认证的资源(或公共资源)
return !$resource->no_auth;
return !$resource->no_auth && $system->id == $resource->system_id;
})
);
}
@@ -52,14 +56,159 @@ class PermLogic
return $all->unique('id')->values()->sortBy('sort');
}
/**
* 构建目录/菜单树,节点包含权限与打开方式,拼接完整路径,并对子级菜单component处理
* @param \Illuminate\Support\Collection $resources
* @param int $parentId
* @param string $parentPath
* 构建目录/菜单树
*
* @param \Illuminate\Support\Collection $resources 全部资源集合
* @param int $parentId 当前父级资源 ID
* @param string $parentPath 父级完整路径(用于递归)
* @return array
*/
public function buildTree_bak($resources, int $parentId = 0, string $parentPath = ''): array
{
$tree = [];
foreach ($resources as $node) {
// 只处理目录和菜单,不把 permission 当作菜单节点
if ($node->parent_id !== $parentId || $node->type === 'permission') {
continue;
}
// —— 拼接当前节点的完整路径 ——
$currentPath = rtrim($parentPath, '/');
if (!empty($node->url)) {
$urlSegment = ltrim($node->url, '/');
$currentPath = $currentPath === ''
? $urlSegment
: "{$currentPath}/{$urlSegment}";
}
// —— 递归构建子节点 ——
// 这里只会继续收目录和菜单,permission 永远不会进入此方法
$children = $this->buildTree($resources, $node->id, $currentPath);
// —— 本级权限列表 ——
$authList = $resources
->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id)
->map(fn($r) => [
'id' => $r->id,
'title' => $r->name,
'auth_mark' => $r->code,
])
->values()
->all();
// —— 判断是否有子目录或子菜单 ——
$hasChildrenMenu = ! empty($children);
// —— 计算 component 字段 ——
if ($hasChildrenMenu) {
if ($node->type === 'menu' && !empty($node->url)) {
// menu 类型且自身有 url,用自身 url(加前导 slash)
$componentValue = '/' . ltrim($node->url, '/');
} else {
// 其它含子项的节点,固定 Layout 组件
$componentValue = '/index/index';
}
} else {
// 叶子节点:拼接路径,确保以 slash 开头
$componentValue = $currentPath !== ''
? '/' . ltrim($currentPath, '/')
: '';
}
$tree[] = [
'id' => $node->id,
'name' => $node->name,
'path' => $node->url,
'component' => $componentValue,
'meta' => [
'title' => $node->name,
'icon' => $node->icon,
'keepAlive' => true,
'authList' => $authList,
'openType' => $node->open_type,
'isHide' => $node->is_hide == 0 ? false : true,
'isHideTab' => $node->is_hide_tab == 0 ? false : true
],
'children' => $children
];
}
return $tree;
}
public function buildTree_bak2($resources, int $parentId = 0, string $parentPath = ''): array
{
$tree = [];
foreach ($resources as $node) {
if ($node->parent_id !== $parentId) {
continue;
}
// —— 拼接当前节点的完整路径 ——
$currentPath = $parentPath;
// 去掉末尾多余 slash
$currentPath = rtrim($currentPath, '/');
if (!empty($node->url)) {
// 如果当前节点有 url,则拼接
$urlSegment = ltrim($node->url, '/');
$currentPath = $currentPath === ''
? $urlSegment
: "{$currentPath}/{$urlSegment}";
}
// —— 递归构建子节点 ——
$children = [];
if ($node->type === 'directory') {
$children = $this->buildTree($resources, $node->id, $currentPath);
}
// —— 收集本级的 permission 权限列表 ——
$authList = $resources
->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id)
->map(fn($r) => [
'id' => $r->id,
'title' => $r->name,
'auth_mark' => $r->code,
])
->values()
->all();
// —— 判断是否有子菜单/目录 ——
$hasChildrenMenu = $resources
->where('parent_id', $node->id)
->whereIn('type', ['directory', 'menu'])
->isNotEmpty();
// —— 计算 component 字段 ——
if ($hasChildrenMenu) {
// 只要有子项,就一律显示顶层 Layout 组件
$componentValue = '/index/index';
} else {
// 叶子节点:如果定义了 url,就使用拼接后的路径;否则用占位符(这里用空串,你可替换)
$componentValue = $currentPath !== '' ? $currentPath : '';
}
$tree[] = [
'id' => $node->id,
'name' => $node->name,
'path' => $node->url,
'component' => $componentValue,
'meta' => [
'title' => $node->name,
'icon' => $node->icon,
'keepAlive' => true,
'authList' => $authList,
'openType' => $node->open_type,
],
'children' => $children,
];
}
return $tree;
}
public function buildTree($resources, int $parentId = 0, string $parentPath = ''): array
{
$tree = [];
@@ -99,10 +248,7 @@ class PermLogic
$hasChildrenMenu = collect($resources)->where('parent_id', $node->id)
->whereIn('type', ['directory', 'menu'])->isNotEmpty();
// component 字段规则:
// 1. 存在子菜单,固定 '/index/index'
// 2. 否则为当前完整路径(或其它你想要的)
$componentValue = ($hasChildrenMenu && !$node->url && $node->type !== 'directory') ? '/index/index' : $currentPath;
$componentValue = $hasChildrenMenu ? '/index/index' : $currentPath;
$tree[] = [
'id' => $node->id,
@@ -115,6 +261,8 @@ class PermLogic
'keepAlive' => true,
'authList' => $authList,
'openType' => $node->open_type,
'isHide' => $node->is_hide == 0 ? false : true,
'isHideTab' => $node->is_hide_tab == 0 ? false : true
],
'children' => $children,
];
@@ -125,9 +273,9 @@ class PermLogic
/**
* 获取用户所有权限code
*/
public function getPermissionCodes(int $userId): array
public function getPermissionCodes(int $userId, string $systemCode): array
{
$resources = $this->getUserResources($userId);
$resources = $this->getUserResources($userId, $systemCode);
return $resources
->filter(fn($res) => $res->type === 'permission')
->pluck('code')
-79
View File
@@ -8,86 +8,7 @@ use support\Log;
class RoleLogic
{
/**
* 获取角色列表
*/
public function getRoleList(array $params)
{
// 假设我们需要根据system_id进行过滤,其他过滤条件也可以添加
$query = Role::query();
if (isset($params['system_id'])) {
$query->where('system_id', $params['system_id']);
}
if (isset($params['status'])) {
$query->where('status', $params['status']);
}
// 添加分页
$roles = $query->paginate($params['per_page'] ?? 15);
return $roles;
}
/**
* 获取角色详情
*/
public function getRoleDetail(int $roleId)
{
$role = Role::find($roleId);
if (!$role) {
throw new BusinessException('角色不存在', 404);
}
return $role;
}
/**
* 创建角色
*/
public function createRole(array $data)
{
$role = new Role();
$role->fill($data); // 根据字段填充数据
try {
$role->save();
} catch (\Throwable $e) {
Log::error('创建角色失败:'.$e->getMessage());
throw new BusinessException('创建角色失败', 500);
}
return $role;
}
/**
* 更新角色
*/
public function updateRole(array $data)
{
$role = Role::find($data['id']);
if (!$role) {
throw new BusinessException('角色不存在', 404);
}
$role->update($data); // 更新字段
return $role;
}
/**
* 删除角色
*/
public function deleteRole(int $roleId)
{
$role = Role::find($roleId);
if (!$role) {
throw new BusinessException('角色不存在', 404);
}
try {
$role->delete();
} catch (\Throwable $e) {
Log::error('删除角色失败:'.$e->getMessage());
throw new BusinessException('删除角色失败', 500);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\Systems;
class SystemsLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Systems::class;
public function getList()
{
return Systems::where('status', 1)->orderBy('sort', 'asc')->get(['id', 'name', 'code', 'type', 'type']);
}
}
+44
View File
@@ -24,4 +24,48 @@ class UserLogic
return $user;
}
/**
* 用户信息
*/
public function edit(int $uid, array $data)
{
$user = User::find($uid);
if (!$user) {
throw new BusinessException('账户不存在', 404);
}
$user->fill($data);
$user->save();
return $user;
}
/**
* 修改密码
*/
public function savePassword(int $uid, array $data)
{
$password = password_hash($data['password_new'], PASSWORD_DEFAULT);
$res = User::where('id',$uid)->update(['password' => $password]);
return $res;
}
/**
* 获取用户列表
*/
public function getList()
{
$list = User::where('status', 1)->orderBy('id', 'desc')->get();
$arr = [];
foreach ($list->toArray() as $item) {
$arr[] = [
'name' => $item['name'],
'id' => $item['id'],
];
}
return $arr;
}
}
+10
View File
@@ -46,4 +46,14 @@ class Corp extends Model
public $timestamps = true;
protected $dateFormat = 'U';
protected $fillable = [
'name',
'abbr',
'tenant_key',
'sort',
'status'
];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class Country extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'country';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'cn_addr', 'en_addr', 'en_full_name', 'two_letter', 'three_letter', 'num_code', 'steering', 'c_time', 'u_time'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class CrmCustomer extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_customer';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'sn', 'corp_abbr', 'user_id', 'user_sn', 'type', 'grade', 'sex', 'channel_id', 'country_id', 'car_type_id', 'magnitude', 'email', 'phone', 'WhatsApp', 'contacts', 'describe', 'desc_collect', 'address', 'lat', 'lng', 'follow_last', 'follow_next', 'warn', 'follow_remark', 'enter_user_id', 'created_at', 'updated_at', 'deleted_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class InquiryChannel extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_channel';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'name', 'sort', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace app\common\model;
use support\Model;
use app\common\model\User;
use app\common\model\Country;
class InquiryLog extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_log';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = [
'id', 'order_no', 'enter_user_id', 'recipient',
'quality', 'channel', 'email', 'tel', 'whatsapp',
'country', 'contacts', 'content', 'status',
'r_time', 't_time', 'created_at', 'updated_at'
];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
public function enter()
{
return $this->hasOne(User::class, 'id', 'enter_user_id');
}
public function recipient()
{
return $this->hasOne(User::class, 'id', 'recipient');
}
public function channelInfo()
{
return $this->hasOne(InquiryChannel::class, 'id', 'channel');
}
public function countryInfo()
{
return $this->hasOne(Country::class, 'id', 'country');
}
}
+9 -1
View File
@@ -44,6 +44,14 @@ class Role extends Model
protected $dateFormat = 'U';
protected $fillable = [
'system_id',
'name',
'desc',
'sort',
'status'
];
// 角色下所有用户
public function users()
{
@@ -53,6 +61,6 @@ class Role extends Model
// 角色拥有的资源(权限点、菜单)
public function resources()
{
return $this->belongsToMany(Resource::class, 'role_resources', 'role_id', 'resource_id')->orderBy('sort', 'asc');
return $this->belongsToMany(Resource::class, 'role_resources', 'role_id', 'resource_id');
}
}
+10 -25
View File
@@ -4,21 +4,6 @@ namespace app\common\model;
use support\Model;
/**
* systems 系统/应用表
* @property integer $id 系统ID(主键)
* @property string $code 系统编码(唯一英文标识)
* @property string $name 系统名称
* @property string $type 系统类型
* @property string $desc 系统描述
* @property string $icon 系统图标
* @property string $domain_url 系统URL
* @property string $is_role 是否区分角色权限
* @property integer $sort 排序
* @property integer $status 状态
* @property integer $created_at 创建时间
* @property integer $updated_at 更新时间
*/
class Systems extends Model
{
/**
@@ -50,15 +35,15 @@ class Systems extends Model
public $timestamps = true;
// 系统下的资源
public function resources()
{
return $this->hasMany(Resource::class, 'system_id', 'id');
}
protected $dateFormat = 'U';
// 拥有该系统访问权限的用户
public function users()
{
return $this->belongsToMany(User::class, 'user_systems', 'system_id', 'user_id');
}
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'code', 'name', 'type', 'desc', 'icon', 'domain_url', 'is_role', 'sort', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+6 -1
View File
@@ -54,6 +54,7 @@ class User extends Model
'sn',
'name',
'avatar',
'sex',
'password',
'phone',
'email',
@@ -63,7 +64,11 @@ class User extends Model
'union_id',
'user_id',
'open_id',
'tenant_key'
'tenant_key',
'country_id',
'position',
'socialize',
'socialize_pic',
];
// 用户所有角色
-69
View File
@@ -1,69 +0,0 @@
<?php
namespace app\common\model;
use support\Model;
/**
* user_feishu_app 用户-飞书应用
* @property integer $id ID(主键)
* @property integer $uid 用户ID
* @property integer $corp_id 公司ID
* @property string $name 名称
* @property string $mobile 手机
* @property string $union_id ISV的唯一标识
* @property string $user_id 用户 user_id
* @property string $open_id 应用内的唯一标识
* @property string $tenant_key 当前企业标识
* @property integer $sort 排序
* @property integer $status 状态
* @property integer $created_at 创建时间
* @property integer $updated_at 更新时间
*/
class UserFeishuApp_bak extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'user_feishu_app';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
protected $fillable = [
'uid',
'corp_id',
'name',
'mobile',
'union_id',
'user_id',
'open_id',
'tenant_key',
'sort',
'status'
];
}
-9
View File
@@ -86,13 +86,4 @@ class AuthService
{
(new AuthLogic())->blockToken($token, $systemCode);
}
// public function perms()
// {
// $permService = new PermService();
// $menuTree = $permService->getMenuTree($uid);
// $permissionCodes = $permService->getPermissionCodes($uid);
// return (new PermService())->getMenuTree($this->userId)
// }
}
-155
View File
@@ -1,155 +0,0 @@
<?php
namespace app\common\service;
use app\common\logic\AuthLogic;
use app\common\model\User;
use support\Cache;
class AuthService_bak
{
// 缓存 Key
protected static function cacheKey(int $userId): string
{
return "user_resources_{$userId}";
}
/**
* 获取用户所有资源(目录/菜单/权限),合并角色+直接分配,并缓存
*/
public static function getUserResources(int $userId)
{
$cacheKey = self::cacheKey($userId);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$user = User::with(['roles.resources', 'resources'])->find($userId);
if (!$user) {
return collect();
}
// 合并:角色资源 + 用户直接资源
$all = collect();
foreach ($user->roles as $role) {
$all = $all->merge($role->resources);
}
$all = $all->merge($user->resources);
$unique = $all->unique('id')->values();
Cache::set($cacheKey, $unique, 3600);
return $unique;
}
/**
* 构建目录/菜单树,并给每个菜单节点挂载权限列表和打开方式
*/
public static function getMenuTree(int $userId): array
{
$resources = self::getUserResources($userId);
$menus = $resources
->filter(fn($r) => in_array($r->type, ['directory','menu']))
->values();
return self::buildTree($menus, $resources);
}
protected static function buildTree($nodes, $allResources, int $parentId = 0): array
{
$tree = [];
foreach ($nodes as $node) {
if ($node->parent_id !== $parentId) continue;
// 子节点
$children = self::buildTree($nodes, $allResources, $node->id);
// 本级权限列表
$authList = $allResources
->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id)
->map(fn($r) => [
'id' => $r->id,
'title' => $r->name,
'auth_mark' => $r->code,
])->values()->all();
$tree[] = [
'id' => $node->id,
'name' => $node->name,
'path' => $node->url,
'meta' => [
'title' => $node->name,
'icon' => $node->icon,
'keepAlive' => true,
'authList' => $authList,
// 新增打开方式字段
'openType' => $node->open_type, // 'internal' | 'external' | 'iframe'
],
'children' => $children,
];
}
return $tree;
}
/**
* 获取用户所有权限 code(仅 type=permission)
*/
public static function getPermissionCodes(int $userId): array
{
return self::getUserResources($userId)
->filter(fn($res) => $res->type === 'permission')
->pluck('code')
->unique()
->values()
->all();
}
/** 清除缓存(角色/资源变更时调用) */
public static function clearCache(int $userId): void
{
Cache::delete(self::cacheKey($userId));
}
public function login(array $data): array
{
$loginType = $data['login_type'] ?? 'upwd';
$logic = new AuthLogic();
return match ($loginType) {
'feishu' => $logic->feishuLogin($data),
'qrcode' => $logic->qrCodeLogin($data),
default => $logic->uPwdLogin($data),
};
}
/**
* 用户注册
*
* @param array $data
* @return User
*/
public function register(array $data): User
{
return (new AuthLogic())->register($data);
}
public function verifyToken(string $token): array
{
return (new AuthLogic())->verifyToken($token);
}
public function refreshToken(string $refreshToken): array
{
return (new AuthLogic())->refreshToken($refreshToken);
}
public function logout(?string $token)
{
(new AuthLogic())->logout($token);
}
public function blockToken(string $token)
{
(new AuthLogic())->blockToken($token);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class BasicDataService
public function checkAndCacheBaseParams($loginType, $systemCode, $corpAbbr)
{
if (!$loginType && !$systemCode && !$corpAbbr) {
throw new BusinessException(htmlpage());
throw new BusinessException('header缺少必要参数');
}
// 检查并缓存登录类型
+2 -2
View File
@@ -14,8 +14,8 @@ class CorpFeishuAppService
$this->logic = (new CorpFeishuAppLogic());
}
public function getFeishuApp(int $corpId)
public function getFeishuApp(int $corpId, int $systemId)
{
return $this->logic->getFeishuApp($corpId);
return $this->logic->getFeishuApp($corpId, $systemId);
}
}
+11 -11
View File
@@ -15,23 +15,23 @@ class PermService
}
// 用户资源缓存key
protected static function cacheKey(int $userId): string
protected static function cacheKey(int $userId, string $systemCode): string
{
return "user_resources_{$userId}";
return "user_resources_{$userId}_{$systemCode}";
}
/**
* 获取用户所有资源(合并角色+直接分配),结果缓存
*/
public function getUserResources(int $userId)
public function getUserResources(int $userId, string $systemCode)
{
$cacheKey = self::cacheKey($userId);
$cacheKey = self::cacheKey($userId, $systemCode);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
// 调用 Logic 层获取资源
$resources = $this->logic->getUserResources($userId);
$resources = $this->logic->getUserResources($userId, $systemCode);
// 缓存结果
Cache::set($cacheKey, $resources, 3600);
return $resources;
@@ -40,26 +40,26 @@ class PermService
/**
* 构建目录/菜单树,节点包含权限与打开方式
*/
public function getMenuTree(int $userId): array
public function getMenuTree(int $userId, string $systemCode): array
{
// 调用 Logic 层处理菜单构建
$resources = $this->getUserResources($userId);
$resources = $this->getUserResources($userId, $systemCode);
return $this->logic->buildTree($resources);
}
/**
* 获取用户所有权限code
*/
public function getPermissionCodes(int $userId): array
public function getPermissionCodes(int $userId, string $systemCode): array
{
return $this->logic->getPermissionCodes($userId);
return $this->logic->getPermissionCodes($userId, $systemCode);
}
/**
* 清除资源相关缓存
*/
public function clearCache(int $userId): void
public function clearCache(int $userId, string $systemCode): void
{
Cache::delete(self::cacheKey($userId));
Cache::delete(self::cacheKey($userId, $systemCode));
}
}
-55
View File
@@ -1,55 +0,0 @@
<?php
namespace app\common\service;
use app\common\logic\RoleLogic;
class RoleService
{
protected $roleLogic;
public function __construct()
{
$this->roleLogic = (new RoleLogic());
}
/**
* 获取角色列表
*/
public function getRoleList(array $params)
{
return $this->roleLogic->getRoleList($params);
}
/**
* 获取角色详情
*/
public function getRoleDetail(int $roleId)
{
return $this->roleLogic->getRoleDetail($roleId);
}
/**
* 创建角色
*/
public function createRole(array $data)
{
return $this->roleLogic->createRole($data);
}
/**
* 更新角色
*/
public function updateRole(array $data)
{
return $this->roleLogic->updateRole($data);
}
/**
* 删除角色
*/
public function deleteRole(int $roleId)
{
return $this->roleLogic->deleteRole($roleId);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace app\common\service;
use app\common\logic\SystemsLogic;
class SystemsService
{
protected $logic;
public function __construct()
{
$this->logic = (new SystemsLogic());
}
/**
* 获取系统list
*/
public function getList()
{
return $this->logic->getList();
}
}
+74 -3
View File
@@ -3,6 +3,11 @@
namespace app\common\service;
use app\common\logic\UserLogic;
use app\common\model\Corp;
use app\common\model\User;
use app\common\utils\CrudUtil;
use app\crm\model\Country;
use support\exception\BusinessException;
class UserService
{
@@ -17,12 +22,12 @@ class UserService
/**
* 获取用户信息(包括菜单和权限)
*/
public function getUserInfo(int $uid)
public function getUserInfo(int $uid, string $systemCode)
{
$user = $this->logic->userInfo($uid);
$permService = new PermService();
$menuTree = $permService->getMenuTree($uid);
$permissionCodes = $permService->getPermissionCodes($uid);
$menuTree = $permService->getMenuTree($uid, $systemCode);
$permissionCodes = $permService->getPermissionCodes($uid, $systemCode);
return [
'user' => [
'id' => $user->id,
@@ -37,4 +42,70 @@ class UserService
'permissions' => $permissionCodes
];
}
/**
* 获取用户信息(包括菜单和权限)
*/
public function getUser(int $uid, $corp_abbr)
{
$user = $this->logic->userInfo($uid);
$corp_name = Corp::where('abbr', $corp_abbr)->value('name');
$ossservice = new OssService();
return [
'id' => $user->id,
'name' => $user->name,
'position' => $user->position,
'socialize' => $user->socialize,
'socialize_pic' => $user->socialize_pic,
'socialize_pic_full' => (!empty($user->socialize_pic))?$ossservice->generatePrivateUrl($user->socialize_pic):'',
'country_id' => $user->country_id,
'country_txt' => Country::where('id',$user->country_id)->value('cn_addr'),
'avatar' => $user->avatar,
'avatar_full' => (!empty($user->avatar))?$ossservice->generatePrivateUrl($user->avatar):'',
'phone' => $user->phone,
'corp_name' => $corp_name,
'email' => $user->email,
'status' => $user->status,
'roles' => $user->roles->pluck('name')
];
}
/**
* 编辑用户信息
*/
public function edit(int $uid, array $data)
{
unset($data['password']);
unset($data['phone']);
unset($data['email']);
unset($data['sn']);
$user = $this->logic->edit($uid,$data);
return $user;
}
/**
* 修改用户密码
*/
public function savePassword(int $uid, array $data)
{
// 对密码进行哈希处理
$password = $data['password'];
$user = User::find($uid);
if (!password_verify($password, $user->password)) {
throw new BusinessException('密码错误');
}
$user = $this->logic->savePassword($uid,$data);
return $user;
}
/**
* 获取用户列表
*/
public function getList()
{
return $this->logic->getList();
}
}
+80 -64
View File
@@ -1,47 +1,51 @@
<?php
namespace app\common\traits;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* 数据转换器 Trait
* 用于将原始数据(如模型、集合、分页等)统一转换为数组格式,
* 并支持字段级与结果级的灵活后置加工。
*/
trait DataTransformer
{
/**
* 核心方法:对任意数据进行转换并返回数组
* 核心转换方法
*
* @param mixed $rawData 原始数据,可为 Paginator / Model / Collection / array 等
* @param callable|null $postProcessor 可选的后置处理回调,接受处理完的数组再返回新的数组
* @param callable|null $fieldFormatter 可选的字段命名转换回调,如 snake_case
* @param mixed $rawData 原始数据:Model、Collection、Paginator、数组等
* @param array $opts 可选配置:
* - 'fieldFormatter' => callable 字段名格式化回调,如 fn($f)=>snake_case($f)
* - 'postFormatter' => callable 结果级加工回调,接收整个数组并返回
*
* @return array
* @return array 转换并加工后的纯 PHP 数组
*/
protected function transformData(
mixed $rawData,
?callable $postProcessor = null,
?callable $postFormatter = null,
?callable $fieldFormatter = null
): array {
try {
$fieldFormatter = $fieldFormatter ?: fn($field) => $field;
if ($rawData instanceof LengthAwarePaginator) {
$formatted = $this->formatPaginated($rawData, $postProcessor, $fieldFormatter);
$arr = $this->formatPaginated($rawData, $postFormatter, $fieldFormatter);
} elseif ($rawData instanceof Model) {
$formatted = $this->formatModel($rawData, $fieldFormatter);
$arr = $this->formatModel($rawData, $fieldFormatter);
} elseif ($rawData instanceof Collection) {
$formatted = $this->formatCollection($rawData, $fieldFormatter);
$arr = $this->formatCollection($rawData, $fieldFormatter);
} elseif (is_array($rawData)) {
$formatted = $this->formatArray($rawData, $fieldFormatter);
$arr = $this->formatArray($rawData, $fieldFormatter);
} else {
$formatted = (array) $rawData;
$arr = (array)$rawData;
}
if (is_callable($postProcessor)) {
$formatted = $postProcessor($formatted);
if (is_callable($postFormatter)) {
$arr = $postFormatter($arr);
}
return $formatted;
return $arr;
} catch (\Throwable $e) {
return [
'error' => true,
@@ -52,84 +56,96 @@ trait DataTransformer
}
/**
* 格式化分页数据:将每条记录也进行 transformData 递归处理
* 格式化分页数据:转换每条记录
*
* @param LengthAwarePaginator $paginator
* @param callable|null $postProcessor
* @param callable $fieldFormatter
* @return array
* @param LengthAwarePaginator $p
* @param callable $fmt
* @return array{records: array, total: int, current: int, size: int}
*/
protected function formatPaginated(
LengthAwarePaginator $paginator,
?callable $postProcessor,
callable $fieldFormatter
LengthAwarePaginator $p,
?callable $postFormatter,
callable $fmt
): array {
$list = $paginator->getCollection()->map(function ($item) use ($postProcessor, $fieldFormatter) {
return $this->transformData($item, $postProcessor, $fieldFormatter);
})->toArray();
$records = $p->getCollection()
->map(fn($item) => $this->transformData($item, $postFormatter, $fmt))
->toArray();
return [
'records' => $list,
'total' => $paginator->total(),
'current' => $paginator->currentPage(),
'size' => $paginator->perPage()
'records' => $records,
'total' => $p->total(),
'current' => $p->currentPage(),
'size' => $p->perPage(),
];
}
/**
* 格式化 Eloquent 模型:递归嵌套关联
* 格式化 Eloquent 模型:主表属性 + 关联递归
*
* @param Model $model
* @param callable $fieldFormatter
* @param callable $fmt
* @return array
*/
protected function formatModel(Model $model, callable $fieldFormatter): array
{
$formatted = [];
// 主表字段
protected function formatModel(
Model $model,
callable $fmt
): array {
$out = [];
// 1) 主表字段
foreach ($model->getAttributes() as $key => $value) {
$formatted[$fieldFormatter($key)] = $value;
$out[$fmt($key)] = $value;
}
// 关联数据,递归嵌套
foreach ($model->getRelations() as $relation => $relationData) {
if ($relationData instanceof Model) {
// 单个关联模型
$formatted[$relation] = $this->formatModel($relationData, $fieldFormatter);
} elseif ($relationData instanceof Collection) {
// 关联集合
$formatted[$relation] = $relationData
->map(fn($item) => $this->transformData($item, null, $fieldFormatter))
// 2) 关联递归
foreach ($model->getRelations() as $rel => $relData) {
if ($relData instanceof Model) {
$out[$rel] = $this->formatModel($relData, $fmt);
} elseif ($relData instanceof Collection) {
$out[$rel] = $relData
->map(fn($item) => $this->transformData($item, null, $fmt))
->toArray();
} else {
$out[$rel] = $relData;
}
}
return $formatted;
return $out;
}
/**
* 格式化集合
* 格式化集合数据:对每个元素递归调用 transformData
*
* @param Collection $collection
* @param callable $fieldFormatter
* @param Collection $c
* @param callable $fmt
* @return array
*/
protected function formatCollection(Collection $collection, callable $fieldFormatter): array
{
return $collection->map(fn($item) => $this->transformData($item, null, $fieldFormatter))
->toArray();
protected function formatCollection(
Collection $c,
callable $fmt
): array {
return $c
->map(fn($item) => $this->transformData($item, null, $fmt))
->toArray();
}
/**
* 格式化普通数组
* 格式化普通数组:递归处理可能出现的模型或集合
*
* @param array $array
* @param callable $fieldFormatter
* @param array $arr
* @param callable $fmt
* @return array
*/
protected function formatArray(array $array, callable $fieldFormatter): array
{
return array_map(fn($item) => $this->transformData($item, null, $fieldFormatter), $array);
protected function formatArray(
array $arr,
callable $fmt
): array {
return array_map(function($item) use ($fmt) {
if (is_array($item)
|| $item instanceof Model
|| $item instanceof Collection
|| $item instanceof LengthAwarePaginator
) {
return $this->transformData($item, null, $fmt);
}
return $item;
}, $arr);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace app\common\traits;
use support\Request;
trait FormatKvTrait
{
/**
* 主入口:一行把前端扁平化参数变成完整的查询配置
*
* @param Request $request
* @param string $modelClass Eloquent 模型类名
* @return array{
* model: string,
* where: array,
* order: array<string,string>,
* fields: string[],
* include: string[]
* }
*/
protected function buildQueryConfig(Request $request, string $modelClass): array
{
$all = $request->all();
return [
'model' => $modelClass,
'where' => $this->parseFilters($all),
'order' => $this->parseSort($all['sort'] ?? ''),
'fields' => $this->parseCsv($all['fields'] ?? ''),
'include' => $this->parseCsv($all['include'] ?? ''),
];
}
/**
* 把除 sort/fields/include/page/limit 以外的所有参数
* 智能转换为 where 条件
*
* 支持:
* - 等值:foo=bar
* - NOT:foo=!bar 或 foo=!bar1,bar2
* - 范围:foo=2025-07-01,2025-07-15 → between
* - 列表:foo=a,b,c → in
* - NULL:foo=null
* - NOT NULL:foo=!null
* - 通配符:foo=xia* → like 'xia%'
*
* 关联字段直接写 relation.field=xxx
*/
protected function parseFilters(array $params): array
{
$where = [];
$reserved = ['sort','fields','include','page','limit'];
foreach ($params as $key => $val) {
if (in_array($key, $reserved, true)) {
continue;
}
// NULL / NOT NULL
if ($val === 'null') {
$where[$key] = ['is null'];
continue;
}
if ($val === '!null') {
$where[$key] = ['is not null'];
continue;
}
// NOT equal or NOT IN
if (str_starts_with($val, '!')) {
$v = substr($val, 1);
if (str_contains($v, ',')) {
$list = array_map('trim', explode(',', $v));
$where[$key] = ['not in', $list];
} else {
$where[$key] = ['<>', $this->cast($v)];
}
continue;
}
// BETWEEN or IN
if (str_contains($val, ',')) {
$parts = array_map('trim', explode(',', $val));
if (count($parts) === 2 && $this->isRangeValue($parts[0], $parts[1])) {
$where[$key] = ['between', $parts];
} else {
$where[$key] = ['in', $this->castArray($parts)];
}
continue;
}
// LIKE
if (str_contains($val, '*') || str_contains($val, '%')) {
$pattern = str_replace('*', '%', $val);
$where[$key] = ['like', $pattern];
continue;
}
// 默认 EQ
$where[$key] = $this->cast($val);
}
return $where;
}
/**
* 解析 sort=id,-created_at → ['id'=>'asc','created_at'=>'desc']
*/
protected function parseSort(string $sort): array
{
$order = [];
foreach (explode(',', $sort) as $piece) {
$f = trim($piece);
if ($f === '') {
continue;
}
if ($f[0] === '-') {
$order[substr($f, 1)] = 'desc';
} else {
$order[$f] = 'asc';
}
}
return $order;
}
/**
* 逗号分割成数组
*/
protected function parseCsv(string $csv): array
{
if ($csv === '') {
return [];
}
return array_filter(array_map('trim', explode(',', $csv)));
}
/** 简单类型转换:全数字→int/float,其余保留原字符串 */
protected function cast(string $v): mixed
{
if (is_numeric($v)) {
return strpos($v, '.') !== false ? (float)$v : (int)$v;
}
return $v;
}
/** 批量类型转换 */
protected function castArray(array $arr): array
{
return array_map(fn($v) => $this->cast($v), $arr);
}
/** 判断两端是否可做 BETWEEN(数字或 YYYY-MM-DD 日期) */
protected function isRangeValue(string $a, string $b): bool
{
return (is_numeric($a) && is_numeric($b))
|| (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a)
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $b));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace app\common\traits;
use think\Validate;
use support\Request;
use support\exception\BusinessException;
trait RequestTrait
{
/**
* 验证数据
*
* @param Validate $validator 验证器实例
* @param array $data 待验证的数据
* @param string $scene 验证场景
* @return array 验证后的数据
* @throws BusinessException 验证失败时抛出业务异常
*/
public function validate(Validate $validator, array $data, string $scene = ''): array
{
// 如果指定了验证场景,并且验证器支持场景方法,则设置验证场景
if ($scene && method_exists($validator, 'scene')) {
$validator->scene($scene);
}
// 执行数据验证
if (!$validator->check($data)) {
// 验证失败,抛出业务异常,异常信息为验证器返回的错误信息
throw new BusinessException($validator->getError());
}
// 验证成功,返回原始数据
return $data;
}
}
@@ -0,0 +1,148 @@
<?php
namespace app\common\traits\crud;
use support\Request;
/**
* CRUD 操作控制器 Trait
* 提供通用的增删改查控制器方法,通过调用 service 层实现业务逻辑解耦。
*/
trait CrudControllerTrait
{
/**
* 获取列表
*
* @param Request $request 请求对象
* @return mixed 返回分页数据及总数
*/
public function index(Request $request)
{
try {
$raw = $this->service->serviceList($request);
$data = $this->transformData($raw, method_exists($this, 'indexFormat')
? [$this, 'indexFormat']
: null);
return $this->success($data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 获取详情
*
* @param Request $request 请求对象
* @param int|string $id 资源 ID
* @return mixed 返回资源详情数据
*/
public function show(Request $request)
{
try {
$raw = $this->service->serviceShow($request);
$data = $this->transformData($raw, method_exists($this, 'showFormat')
? [$this, 'showFormat']
: null);
return $this->success($data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 创建
*
* @param Request $request 请求对象
* @return mixed 返回创建成功的数据或信息
*/
public function store(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'store');
$res = $this->service->serviceStore($request, $data);
return $this->success($res, '创建成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 更新
*
* @param Request $request 请求对象
* @return mixed 返回更新成功的数据或信息
*/
public function update(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'update');
$res = $this->service->serviceUpdate($request, $data);
return $this->success([], '更新成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 删除
*
* @param Request $request 请求对象
* @return mixed 返回删除操作结果
*/
public function destroy(Request $request)
{
try {
$count = $this->service->serviceDestroy($request);
return $this->success([], "成功删除了 {$count} 条");
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
// ----------------------------------------------------------------
// 以下三个钩子方法,**在使用此 Trait 的 Controller** 中实现
// ----------------------------------------------------------------
/**
* 字段名格式化:例如全部下划线
*
* @param string $field
* @return string
*/
// protected function fieldFormatter(string $field): string
// {
// return snake_case($field);
// }
/**
* 字段级后置加工规则
*
* @return array<string,callable>
* 键为 “点号嵌套”或 “通配符” 字段路径,
* 值为 fn($value, $row)=>mixed
*/
// protected function getFieldTransforms(): array
// {
// return [
// 'name' => fn($v,$row)=>strtoupper($v),
// 'orders.*.total' => fn($v,$row)=>number_format($v,2),
// ];
// }
/**
* 结果级后置加工:接收整个数组,可任意增删改
*
* @param array $data
* @return array
*/
// protected function postFormat(array $data): array
// {
// // 例如重命名 records 为 items
// if (isset($data['records'])) {
// $data['items'] = $data['records'];
// unset($data['records']);
// }
// return $data;
// }
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace app\common\traits\crud;
use app\common\utils\CrudUtil;
/**
* Trait CrudLogicTrait
*
* 提供最基础的 CRUD 和复杂查询方法,基于 CrudUtil。
* 使用此 Trait 的类,须定义:
* protected string $modelClass = \App\common\model\YourModel::class;
*/
trait CrudLogicTrait
{
/**
* 分页列表
*/
public function logicList(array $config, int $limit, int $page)
{
$config['model'] = $this->modelClass;
return CrudUtil::paginate(CrudUtil::buildQuery($config), $limit, $page);
}
/**
* 全量列表
*/
public function logicListAll(array $config)
{
$config['model'] = $this->modelClass;
return CrudUtil::getAll(CrudUtil::buildQuery($config));
}
/**
* 查询单条
*/
public function logicShow(array $config)
{
return CrudUtil::findOneBy(CrudUtil::buildQuery($config));
}
/**
* 创建(可带 dataLimit)
*/
public function logicCreate(array $data, array $dataLimit = [])
{
return CrudUtil::create($this->modelClass, $data, $dataLimit);
}
/**
* 任意条件更新(可带 dataLimit)
*/
public function logicUpdateBy(array $config, array $data)
{
return CrudUtil::updateBy(CrudUtil::buildQuery($config), $data);
}
/**
* 任意条件删除(可带 dataLimit)
*/
public function logicDeleteBy(array $config)
{
return CrudUtil::deleteBy(CrudUtil::buildQuery($config));
}
/**
* 复杂查询回调
*/
public function logicComplex(callable $callback)
{
return CrudUtil::executeComplex($this->modelClass, $callback);
}
/**
* 根据主键查询
*/
public function findByPk(int $id, array $with = [])
{
return CrudUtil::findByPk($this->modelClass, $id, $with);
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace app\common\traits\crud;
use support\Request;
/**
* Trait CrudServiceTrait
*
* 提供标准的增删改查 Service 接口,依赖 CrudLogicTrait。
* 使用此 Trait 的 Service 类,须:
* use CrudLogicTrait, CrudServiceTrait;
* protected string $modelClass = \App\common\model\YourModel::class;
*/
trait CrudServiceTrait
{
use CrudLogicTrait;
/** 具体 Service 里定义:protected string $modelClass = YourModel::class; */
/**
* 获取验证器
*/
public function getValidator()
{
return $this->validator;
}
/** 分页列表 */
public function serviceList(Request $request)
{
if (method_exists($this, 'complexQuery')) {
$result = $this->complexQuery($request->get());
} else {
$config = $this->buildQueryConfig($request, 'index');
$limit = (int)$request->get('size', 15);
$page = (int)$request->get('current', 1);
$result = $this->logicList($config, $limit, $page);
}
return $this->postProcess('List', $result);
}
/** 全量列表 */
public function serviceListAll(Request $request)
{
if (method_exists($this, 'complexQuery')) {
$result = $this->complexQuery($request->get());
} else {
$config = $this->buildQueryConfig($request, 'default');
$result = $this->logicListAll($config);
}
return $this->postProcess('List', $result);
}
/** 查看单条 */
public function serviceShow(Request $request)
{
$config = $this->buildQueryConfig($request, 'show');
$item = $this->logicShow($config);
return $this->postProcess('Show', $item);
}
/** 创建 */
public function serviceStore(Request $request, array $data = [])
{
$data = $this->preProcess('Store', $data, $request);
$dl = $this->getDataLimitFromRequest($request);
$created = $this->logicCreate($data, $dl);
return $this->postProcess('Store', $created);
}
/** 更新 */
public function serviceUpdate(Request $request, array $data = [])
{
$data = $this->preProcess('Update', $data, $request);
$config = $this->buildQueryConfig($request, 'update');
$updated = $this->logicUpdateBy($config, $data);
return $this->postProcess('Update', $updated);
}
/** 删除 */
public function serviceDestroy(Request $request)
{
$config = $this->buildQueryConfig($request, 'destroy');
$deleted = $this->logicDeleteBy($config);
return $this->postProcess('Destroy', $deleted);
}
/** ----------------- 公共辅助方法 ----------------- */
/**
* 构造 CrudUtil 查询配置
*/
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'default':
$config['where']['status'] = 1;
$config['order']['sort'] = 'desc';
$config['order']['id'] = 'desc';
break;
case 'index':
$config['where']['status'] = 1;
$config['order']['sort'] = 'desc';
$config['order']['id'] = 'desc';
break;
case 'show':
$config['where']['id'] = $p['id'];
$config['where']['status'] = 1;
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
/** 解析 withFields 字符串 "rel:field,field;..." */
protected function parseWithFields(string $str): array
{
if (!$str) {
return [];
}
$out = [];
foreach (explode(';', $str) as $pair) {
[$rel, $flds] = explode(':', $pair);
$out[$rel] = explode(',', $flds);
}
return $out;
}
/** 从请求读取 dataLimitType, dataLimitField */
protected function getDataLimitFromRequest(Request $req): array
{
$type = $req->get('dataLimitType');
if (!$type) {
return [];
}
return [
'type' => $type,
'field' => $req->get('dataLimitField', 'admin_id'),
];
}
/**
* $data 前置处理
*
* @param array $data
* @param Request $request
* @return array
*/
protected function preProcess(string $op, mixed $data, Request $request): mixed
{
$hook = "beforeService{$op}";
return method_exists($this, $hook)
? $this->{$hook}($data, $request)
: $data;
}
/**
* 根据操作类型,调用对应的后置钩子
*
* @param string $op 操作:List, Show, Store, Update, Destroy
* @param mixed $data 原始返回值
* @return mixed 处理后的返回值
*/
protected function postProcess(string $op, mixed $data): mixed
{
$hook = "afterService{$op}";
return method_exists($this, $hook)
? $this->{$hook}($data)
: $data;
}
/**
* 处理复杂查询
* 覆盖此方法,实现复杂查询逻辑
*/
// protected function complexQuery(array $params): array
// {
// throw new \LogicException('请在子类实现 complexQuery');
// }
}
+431
View File
@@ -0,0 +1,431 @@
<?php
namespace app\common\utils;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use plugin\admin\app\common\Auth;
use support\exception\BusinessException;
/**
* 构建查询工具类
* | 方法 | 说明 |
* | ------------------------------- | --------------------- |
* | `buildQuery($config)` | 构建动态查询(关联加载/字段/过滤/排序) |
* | `paginate($q,$per,$page)` | 分页返回 Paginator |
* | `getAll($q)` | 不分页直接 `get()` |
* | `findByPk($model,$id)` | 根据主键查询单条,找不到抛 404 |
* | `findOneBy($model,$where)` | 根据任意条件查询单条,找不到抛 404 |
* | `deleteByPk($model,$ids)` | 根据主键删除(可批量),返回删除条数 |
* | `deleteBy($model,$where)` | 根据任意条件删除,返回删除条数 |
* | `updateByPk($model,$id,$data)` | 根据主键更新,找不到抛 404 |
* | `updateBy($model,$where,$data)` | 根据任意条件更新,返回影响行数 |
*/
class CrudUtil
{
/**
* 构建查询,支持:
* - 本表字段过滤 where
* - 关联加载 with
* - 关联表字段选择 withFields
* - 关联表字段过滤 where with dot-notation
* - 排序 order
* - 本表字段选择 fields
*
* $config = [
* 'model' => User::class,
* 'where' => [
* 'status' => 1,
* 'name' => ['like','%张%'],
* 'roles.name' => '管理员', // 关联表字段过滤
* 'posts.created_at'=> ['>','2025-01-01'],
* ],
* 'order' => ['id'=>'desc'],
* 'fields' => ['id','name','email'],
* 'with' => ['roles','posts'],
* 'withFields' => [
* 'roles' => ['id','name'], // 关联表字段选择
* 'posts' => ['id','title','user_id'],
* ],
* ];
*/
public static function buildQuery(array $config): Builder
{
/** @var Model $model */
$model = new $config['model'];
$q = $model->newQuery();
// eager-load:支持 ['rel','rel2'] 和 ['rel'=>fields[], ...]
if (! empty($config['with'])) {
foreach ($config['with'] as $rel => $fields) {
if (is_int($rel)) {
// 普通关联
$q->with($fields);
} else {
// 带字段筛选的关联
$q->with([$rel => function ($qr) use ($fields) {
$key = $qr->getModel()->getKeyName();
if (! in_array($key, $fields, true)) {
$fields[] = $key;
}
$qr->select($fields);
}]);
}
}
}
// 表字段
if (!empty($config['fields'])) {
$q->select($config['fields']);
}
// where
if (!empty($config['where'])) {
foreach ($config['where'] as $col => $val) {
if (strpos($col, '.') !== false) {
// 多层支持
$segments = explode('.', $col);
$leaf = array_pop($segments);
self::applyNestedWhere($q, $segments, $leaf, $val);
} else {
self::applyWhere($q, $col, $val);
}
}
}
// or where
if (!empty($config['orWhere'])) {
$q->where(function($q) use($config) {
foreach ($config['orWhere'] as $cond) {
[$col,$op,$val] = $cond;
$q->orWhere($col,$op,$val);
}
});
}
// 排序
if (!empty($config['order'])) {
foreach ($config['order'] as $col => $dir) {
$q->orderBy($col, strtolower($dir) === 'asc' ? 'asc' : 'desc');
}
}
// 数据权限过滤
if (!empty($config['dataLimit'])) {
self::applyDataLimit($q, $config['dataLimit']);
}
return $q;
}
/**
* 分页查询
*
* @param Builder $query
* @param int $perPage
* @param int $page
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public static function paginate(Builder $q, int $perPage, int $page)
{
return $q->paginate($perPage, ['*'], 'page', $page);
}
/**
* 非分页查询
*
* @param Builder $query
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getAll(Builder $q)
{
return $q->get();
}
/**
* 执行复杂查询——仅在显式调用时生效
*
* @param string $modelClass 模型类
* @param callable $callback function(Builder $q) { ... }
* @return mixed 由 callback 返回的结果
*/
public static function executeComplex(string $modelClass, callable $cb)
{
/** @var Model $model */
$model = new $modelClass;
return $cb($model->newQuery());
}
/**
* 主键单条查询
*
* @param string $modelClass
* @param mixed $id
* @param array $with
* @return Model
* @throws BusinessException
*/
public static function findByPk(string $modelClass, $id, array $with = []): Model
{
$m = $modelClass::with($with)->find($id);
if (!$m) {
throw new BusinessException("记录 ID={$id} 不存在", 404);
}
return $m;
}
/**
* 任意条件单条查询,支持 dataLimit
*
* @param Builder $query
* @return Model
* @throws BusinessException
*/
public static function findOneBy(Builder $q): Model
{
return $q->first();
}
/**
* 创建,支持 dataLimit
*
* @param string $modelClass
* @param array $data
* @param array $dataLimit
* @return Model
* @throws BusinessException
*/
public static function create(
string $modelClass,
array $data,
array $dataLimit = []
): Model {
// dataLimit 检查/注入
if (!empty($dataLimit)) {
self::applyDataLimitOnMutate($data, $dataLimit);
}
return $modelClass::create($data);
}
/**
* 主键更新,支持 dataLimit
*
* @param string $modelClass
* @param mixed $id
* @param array $data
* @param array $dataLimit
* @return Model
* @throws BusinessException
*/
public static function updateByPk(
string $modelClass,
$id,
array $data,
array $dataLimit = []
): Model {
/** @var Model $m */
$m = $modelClass::find($id);
if (!$m) {
throw new BusinessException("记录 ID={$id} 不存在", 404);
}
// dataLimit 校验
if (!empty($dataLimit)) {
self::applyDataLimitOnMutate($m, $dataLimit, $data);
}
$m->fill($data)->save();
return $m;
}
/**
* 非主键条件更新(批量),支持 dataLimit
*
* @param Builder $query
* @param array $data
* @return int 受影响行数
*/
public static function updateBy(Builder $q, array $data): int
{
return $q->update($data);
}
/**
* 主键删除(支持批量)
*
* @param string $modelClass
* @param array $ids
* @return int 删除条数
*/
public static function deleteByPk(string $modelClass, array $ids): int
{
return $modelClass::destroy($ids);
}
/**
* 非主键条件删除,支持 dataLimit
*
* @param Builder $query
* @return int 删除条数
*/
public static function deleteBy(Builder $q): int
{
return $q->delete();
}
//=================== Internal Methods ===================//
/**
* 支持多层的 whereHas:$relations=['roles','permissions'],$leaf='name'
*/
protected static function applyNestedWhere(Builder $q, array $relations, string $leaf, $val): void
{
$rel = array_shift($relations);
$q->whereHas($rel, function (Builder $qr) use ($relations, $leaf, $val) {
if ($relations) {
// 还有更深层,继续递归
self::applyNestedWhere($qr, $relations, $leaf, $val);
} else {
// 最后一层,使用普通 applyWhere
self::applyWhere($qr, $leaf, $val);
}
});
}
/**
* 单字段 where 支持:
* - 多条件数组 [ [op1,val1], [op2,val2], … ]
* - 单条件 ['op', val]
* - 直接值
*
* @param Builder $q
* @param string $col
* @param mixed $val
* @return void
*/
protected static function applyWhere(Builder $q, string $col, $val): void
{
// 多条件数组
if (is_array($val) && isset($val[0]) && is_array($val[0])) {
foreach ($val as $pair) {
if (is_array($pair) && count($pair) === 2) {
self::applySingleWhere($q, $col, $pair[0], $pair[1]);
}
}
}
// 单条件
elseif (is_array($val) && count($val) === 2) {
self::applySingleWhere($q, $col, $val[0], $val[1]);
}
// 直接等值
else {
$q->where($col, $val);
}
}
/**
* 执行条件 where
*/
protected static function applySingleWhere(Builder $q, string $col, string $op, $v): void
{
switch (strtolower($op)) {
case 'like':
$q->where($col, 'like', $v);
break;
case 'in':
$q->whereIn($col, (array)$v);
break;
case 'not in':
$q->whereNotIn($col, (array)$v);
break;
case 'null':
$q->whereNull($col);
break;
case 'not null':
$q->whereNotNull($col);
break;
case 'between':
$q->whereBetween($col, (array)$v);
break;
default:
$q->where($col, $op, $v);
}
}
/**
* 应用数据权限过滤到查询(buildQuery 场景)
*/
protected static function applyDataLimit(Builder $q, array $dataLimit): void
{
if (Auth::isSuperAdmin()) {
return;
}
$type = $dataLimit['type'] ?? null;
$field = $dataLimit['field'] ?? 'admin_id';
if ($type === 'personal') {
$q->where($field, admin_id());
} elseif ($type === 'auth') {
$q->whereIn($field, Auth::getScopeAdminIds(true));
}
}
/**
* 应用数据权限到“创建/更新”操作
*
* - $target 可能是 array|Model 对象
* - $newData 仅在 updateByPk 场景时传入,用以校验变更归属
*/
protected static function applyDataLimitOnMutate(&$target, array $dataLimit, array $newData = []): void
{
if (Auth::isSuperAdmin()) {
return;
}
$type = $dataLimit['type'] ?? null;
$field = $dataLimit['field'] ?? 'admin_id';
// 读取当前归属:create 时 $target 是 array,update 时是 Model
$current = is_array($target) ? null : $target->{$field};
// 1. personal → 只能自己
if ($type === 'personal') {
// create: 强制设置
if (is_array($target)) {
$target[$field] = admin_id();
}
// update: 校验当前归属
elseif ($current !== admin_id()) {
throw new BusinessException("无权操作该记录", 403);
}
// 若要修改归属,也必须和自己一致
if (isset($newData[$field]) && $newData[$field] !== admin_id()) {
throw new BusinessException("无权变更归属字段", 403);
}
}
// 2. auth → 自己及下属
if ($type === 'auth') {
$scope = Auth::getScopeAdminIds(true);
// create: 若前端指定归属,则校验范围;否则默认自己
if (is_array($target)) {
if (isset($target[$field])) {
if (!in_array($target[$field], $scope)) {
throw new BusinessException("无权为指定用户操作", 403);
}
} else {
$target[$field] = admin_id();
}
}
// update: 校验当前归属
elseif (!in_array($current, $scope)) {
throw new BusinessException("无权操作该记录", 403);
}
// 校验更新中指定的新归属
if (isset($newData[$field]) && !in_array($newData[$field], $scope)) {
throw new BusinessException("无权变更归属字段", 403);
}
}
}
}
+555
View File
@@ -0,0 +1,555 @@
<?php
namespace app\common\utils;
use Webman\Exception\BusinessException;
class FeishuMessage
{
protected $app_id = 'cli_a72f13d6c022500d';
protected $app_secret = 'Kxtbq70OQWrEhpltrufRZc76z7kjDqPT';
protected $class = null;
public function __construct()
{
$this->class = new Feishu($this->app_id, $this->app_secret);
}
/**
* 飞书消息模板
* @param string $scene
* @param array $options
*/
public function messageTemplates(string $scene = 'group-send', array $options = [])
{
try {
$template = match ($scene) {
'inquiry-group-send' => $this->groupNewTemplates(
$options['quality'],
$options['order_no'],
$options['channel'],
$options['country'],
$options['enter_user'],
$options['r_time']
),
'inquiry-group-update' => $this->groupUpdateTemplates(
$options['quality'],
$options['order_no'],
$options['channel'],
$options['country'],
$options['enter_user'],
$options['r_time'],
$options['t_time']
),
'inquiry-person-allocation' => $this->personAllocationTemplates(
$options['quality'],
$options['order_no'],
$options['channel'],
$options['country'],
$options['enter_user'],
$options['r_time'],
$options['t_time']
),
'crm-person-follow-up' => $this->personFollowUpTemplates(
$options['contacts'],
$options['follow_next'],
$options['follow_last']
)
};
} catch (\UnhandledMatchError $e) {
throw new BusinessException($e->getMessage());
}
return $template;
}
/**
* 发送飞书消息
* @param array $options
*/
public function messageSend(string $scene = 'group-send', array $options = [])
{
$template = $this->messageTemplates($scene, $options);
$result = $this->class->sendMessages($options['receive_id'], $template, $options['msg_type'], $options['receive_id_type']);
if (!isset($result['code']) || $result['code'] !== 0) {
throw new BusinessException('发送飞书消息失败');
}
return $result;
}
/**
* 线索消息发送
*/
public function groupNewTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "&emsp;**'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **发布人:** '.$enter_user.'\n- **发布时间:** '.$r_time.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "form",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"flex_mode": "stretch",
"horizontal_spacing": "8px",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "auto",
"elements": [
{
"tag": "button",
"text": {
"tag": "plain_text",
"content": "立即跟踪"
},
"type": "primary_filled",
"width": "fill",
"size": "medium",
"icon": {
"tag": "standard_icon",
"token": "list-check-bold_outlined"
},
"confirm": {
"title": {
"tag": "plain_text",
"content": "确认跟踪?"
},
"text": {
"tag": "plain_text",
"content": "客户线索:'.$order_no.'"
}
},
"behaviors": [
{
"type": "callback",
"value": "'.$order_no.'"
}
],
"form_action_type": "submit",
"name": "Button_m7d0zqa2",
"element_id": "track"
}
],
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top"
},
{
"tag": "column",
"width": "auto",
"elements": [],
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top"
},
{
"tag": "column",
"width": "auto",
"elements": [],
"padding": "0px 0px 0px 0px",
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"margin": "0px 0px 0px 0px"
}
],
"margin": "4px 0px 0px 0px"
}
],
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "12px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 0px 0px 0px",
"margin": "0px 0px 0px 0px",
"name": "Form_m7d0zqa1"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "新的【公海】客户线索"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"template": "default",
"icon": {
"tag": "standard_icon",
"token": "ai-common_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
/**
* 线索消息更新
*/
public function groupUpdateTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23', $t_time = '2025-07-24 14:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "&emsp;**'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **发布人:** '.$enter_user.'\n- **发布时间:** '.$r_time.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "跟踪时间:'.$t_time.'",
"text_size": "notation",
"text_align": "left",
"text_color": "green"
},
"icon": {
"tag": "standard_icon",
"token": "chat-done_outlined",
"color": "green"
},
"element_id": "track_time"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "【公海】客户线索"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"text_tag_list": [
{
"tag": "text_tag",
"text": {
"tag": "plain_text",
"content": "已跟踪"
},
"color": "orange"
}
],
"template": "green",
"icon": {
"tag": "standard_icon",
"token": "ai-common_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
/**
* 线索分配消息
*/
public function personAllocationTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23', $t_time = '2025-07-24 14:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "&emsp;**'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **录单人:** '.$enter_user.'\n- **录入时间:** '.$r_time.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "分配时间:'.$t_time.'",
"text_size": "notation",
"text_align": "left",
"text_color": "green"
},
"icon": {
"tag": "standard_icon",
"token": "chat-done_outlined",
"color": "green"
},
"element_id": "allocation_time"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "新的客户线索"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"text_tag_list": [
{
"tag": "text_tag",
"text": {
"tag": "plain_text",
"content": "指定"
},
"color": "yellow"
}
],
"template": "blue",
"icon": {
"tag": "standard_icon",
"token": "todo_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
/**
* 跟进提醒消息
*/
public function personFollowUpTemplates($contacts = '木子李', $follow_next = '2025-07-24 14:41:23', $follow_last = '2025-07-24 14:41:23')
{
$content = '{
"schema": "2.0",
"config": {
"update_multi": true,
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"horizontal_spacing": "8px",
"vertical_spacing": "16px",
"horizontal_align": "left",
"vertical_align": "top",
"padding": "0px 20px 20px 20px",
"elements": [
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [
{
"tag": "markdown",
"content": "- **客户名称:** '.$contacts.'\n- **跟进时间:** '.$follow_next.'",
"text_align": "left",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
]
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": "最近跟进:'.$follow_last.'",
"text_size": "notation",
"text_align": "left",
"text_color": "grey"
},
"icon": {
"tag": "standard_icon",
"token": "chat_outlined",
"color": "grey"
},
"element_id": "track_time"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "跟进提醒"
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"template": "green",
"icon": {
"tag": "standard_icon",
"token": "calendar_colorful"
},
"padding": "12px 12px 12px 20px"
}
}';
return $content;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace app\common\utils;
use support\Db;
class TableInspector
{
/**
* 获取某个表的所有字段信息
*/
public static function getColumns(string $table, ?string $database = null): array
{
$database = $database ?: Db::connection()->getDatabaseName();
$columns = Db::select("
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE,
COLUMN_DEFAULT, COLUMN_COMMENT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
", [$database, $table]);
return array_map(function ($col) {
return [
'name' => $col->COLUMN_NAME,
'type' => $col->DATA_TYPE,
'column_type' => $col->COLUMN_TYPE,
'nullable' => $col->IS_NULLABLE === 'YES',
'default' => $col->COLUMN_DEFAULT,
'comment' => $col->COLUMN_COMMENT,
'extra' => $col->EXTRA,
];
}, $columns);
}
/**
* 获取字段名列表
*/
public static function getColumnNames(string $table, ?string $database = null): array
{
return array_column(self::getColumns($table, $database), 'name');
}
/**
* 获取字段注释 map:字段名 => 注释
*/
public static function getColumnComments(string $table, ?string $database = null): array
{
$columns = self::getColumns($table, $database);
$map = [];
foreach ($columns as $col) {
$map[$col['name']] = $col['comment'];
}
return $map;
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace app\common\utils\scaffold;
use Symfony\Component\Filesystem\Filesystem;
use app\common\utils\TableInspector;
class Generate
{
// 配置项
protected $options = [];
public function __construct(array $options = [])
{
if (empty($options)) {
return;
}
$this->options = $options;
}
public function doGenerate()
{
$fs = new Filesystem();
$templateDir = __DIR__ . '/templates/';
$resultStr = '';
if (!empty($this->options['controller'])) {
$controllerPath = __DIR__ . "/../../../{$this->options['controller']['module']}/controller/".$this->rName($this->options['controller']['name'])."Controller.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['controller']['module']}/controller");
$this->deleteFileIfExists($controllerPath);
$ControllerStub = $this->generateFile($templateDir, 'ControllerStub.stub', $controllerPath, 'controller');
$resultStr .= $ControllerStub;
}
if (!empty($this->options['service'])) {
$servicePath = __DIR__ . "/../../../{$this->options['service']['module']}/service/".$this->rName($this->options['service']['name'])."Service.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['service']['module']}/service");
$this->deleteFileIfExists($servicePath);
$ServiceStub = $this->generateFile($templateDir, 'ServiceStub.stub', $servicePath, 'service');
$resultStr .= $ServiceStub;
}
if (!empty($this->options['logic'])) {
$logicPath = __DIR__ . "/../../../{$this->options['logic']['module']}/logic/".$this->rName($this->options['logic']['name'])."Logic.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['logic']['module']}/logic");
$this->deleteFileIfExists($logicPath);
$LogicStub = $this->generateFile($templateDir, 'LogicStub.stub', $logicPath, 'logic');
$resultStr .= $LogicStub;
}
if (!empty($this->options['model'])) {
$modelPath = __DIR__ . "/../../../{$this->options['model']['module']}/model/".$this->rName($this->options['model']['name']).".php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['model']['module']}/model");
$this->deleteFileIfExists($modelPath);
$ModelStub = $this->generateFile($templateDir, 'ModelStub.stub', $modelPath, 'model');
$resultStr .= $ModelStub;
}
if (!empty($this->options['validate'])) {
$validatePath = __DIR__ . "/../../../{$this->options['validate']['module']}/validate/".$this->rName($this->options['validate']['name'])."Validate.php";
$fs->mkdir(__DIR__ . "/../../../{$this->options['validate']['module']}/validate");
$this->deleteFileIfExists($validatePath);
$ValidatorStub = $this->generateFile($templateDir, 'ValidateStub.stub', $validatePath, 'validate');
$resultStr .= $ValidatorStub;
}
return $resultStr ? $resultStr . "<info>文件生成成功!</info> <br/>" : '未生成任何文件';
}
/**
* 根据模板生成文件
*
* @param string $templateDir 模板目录
* @param string $templateFile 模板文件名
* @param string $outputPath 输出文件路径
*/
protected function generateFile($templateDir, $templateFile, $outputPath, $fileType)
{
$templatePath = $templateDir . $templateFile;
if (!file_exists($templatePath)) {
echo "<error>模板文件 {$templateFile} 不存在!</error> \n";
return;
}
$content = file_get_contents($templatePath);
switch ($fileType) {
case 'controller':
$content = str_replace('{{controllerModule}}', $this->options['controller']['module'], $content);
$content = str_replace('{{serviceModule}}', $this->options['service']['module'], $content);
$content = str_replace('{{serviceName}}', $this->rName($this->options['service']['name']), $content);
$content = str_replace('{{controllerName}}', $this->rName($this->options['controller']['name']), $content);
break;
case 'service':
$content = str_replace('{{serviceModule}}', $this->options['service']['module'], $content);
$content = str_replace('{{validateModule}}', $this->options['validate']['module'], $content);
$content = str_replace('{{validateName}}', $this->rName($this->options['validate']['name']), $content);
$content = str_replace('{{modelModule}}', $this->options['model']['module'], $content);
$content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content);
$content = str_replace('{{serviceName}}', $this->rName($this->options['service']['name']), $content);
break;
case 'logic':
$content = str_replace('{{logicModule}}', $this->options['logic']['module'], $content);
$content = str_replace('{{modelModule}}', $this->options['model']['module'], $content);
$content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content);
$content = str_replace('{{logicName}}', $this->rName($this->options['logic']['name']), $content);
break;
case 'model':
$content = str_replace('{{modelModule}}', $this->options['model']['module'], $content);
$content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content);
$content = str_replace('{{table}}', $this->options['table'], $content);
$content = str_replace('{{fillable}}', $this->fillableFields(), $content);
break;
case 'validate':
$content = str_replace('{{validateModule}}', $this->options['validate']['module'], $content);
$content = str_replace('{{validateName}}', $this->rName($this->options['validate']['name']), $content);
break;
}
file_put_contents($outputPath, $content);
return "生成文件: <info>{$outputPath}</info> <br/>";
}
/**
* 设置字段
*
* @param array $fields 字段数组
*/
public function fillableFields()
{
$tableName = $this->options['table'];
$fields = TableInspector::getColumnNames($tableName);
$str = '';
foreach ($fields as $key => $val) {
if ($key < (count($fields)-1)) {
$str .= "'{$val}', ";
} else {
$str .= "'{$val}'";
}
}
$fields = trim($str, '"');
return $fields;
}
/**
* 如果文件存在,删除它
*
* @param string $path 文件路径
*/
protected function deleteFileIfExists($path)
{
if (file_exists($path)) {
unlink($path);
echo "删除已存在文件: <comment>{$path}</comment> \n";
}
}
/**
* 设置名称
*/
public function rName($name = '')
{
return ucfirst(camelize($name));
}
}
/**
* 下划线转驼峰
* 思路:
* step1.原字符串转小写,原字符串中的分隔符用空格替换,在字符串开头加上分隔符
* step2.将字符串中每个单词的首字母转换为大写,再去空格,去字符串首部附加的分隔符.
*/
function camelize($uncamelized_words, $separator='_') {
$uncamelized_words = $separator . str_replace($separator, " ", strtolower($uncamelized_words));
return ltrim(str_replace(" ", "", ucwords($uncamelized_words)), $separator );
}
/**
* 驼峰命名转下划线命名
* 思路:
* 小写和大写紧挨一起的地方,加上分隔符,然后全部转小写
*/
function uncamelize($camelCaps, $separator='_') {
return strtolower(preg_replace('/([a-z])([A-Z])/', "$1" . $separator . "$2", $camelCaps));
}
@@ -0,0 +1,33 @@
<?php
namespace app\{{controllerModule}}\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\{{serviceModule}}\service\{{serviceName}}Service;
class {{controllerName}}Controller extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new {{serviceName}}Service();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,17 @@
<?php
namespace app\{{logicModule}}\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\{{modelModule}}\model\{{modelName}};
class {{logicName}}Logic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = {{modelName}}::class;
}
@@ -0,0 +1,49 @@
<?php
namespace app\{{modelModule}}\model;
use support\Model;
class {{modelName}} extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = '{{table}}';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = [{{fillable}}];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,31 @@
<?php
namespace app\{{serviceModule}}\service;
use app\{{validateModule}}\validate\{{validateName}}Validate;
use app\common\traits\crud\CrudServiceTrait;
use app\{{modelModule}}\model\{{modelName}};
class {{serviceName}}Service
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = {{modelName}}::class;
/**
* 验证器
*/
protected $validator = {{validateName}}Validate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
@@ -0,0 +1,29 @@
<?php
namespace app\{{validateModule}}\validate;
use think\Validate;
class {{validateName}}Validate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace app\common\validate;
use think\Validate;
class CorpValidate extends Validate
{
protected $rule = [
'id' => 'require|number',
'name' => 'require|max:255',
'abbr' => 'require|in:hesu,xinyouhe,paika,simate,mbaf',
'tenant_key' => 'string',
'sort' => 'number',
'status' => 'require|in:0,1'
];
protected $message = [
'id.require' => 'ID不能为空',
'id.number' => 'ID必须是数字',
'name.require' => '企业名称不能为空',
'name.max' => '企业名称长度不能超过255个字符',
'abbr.require' => '企业缩写不能为空',
'abbr.in' => '企业缩写必须是 hesu,xinyouhe,paika,simate,mbaf 中的一个',
'tenant_key.string' => '飞书企业标识必须是字符串',
'sort.number' => '排序值必须是数字',
'status.require' => '状态不能为空',
'status.in' => '状态必须是0或1'
];
protected $scene = [
'store' => ['name', 'abbr', 'tenant_key', 'sort', 'status'],
'update' => ['id', 'name', 'abbr', 'tenant_key', 'sort', 'status']
];
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace app\common\validate;
use think\Validate;
use app\common\model\InquiryLog;
class InquiryLogValidate extends Validate
{
// 规则
protected $rule = [
'id' => 'require|integer|gt:0',
'order_no' => 'require|alphaDash|length:36',
'enter_user_id'=> 'integer|egt:0',
'recipient' => 'integer|egt:0',
'quality' => 'require|in:1,2,3',
'channel' => 'integer|between:0,99',
'email' => 'email|max:255|checkEmailContactUnique',
'tel' => 'max:255|checkTelContactUnique',
'whatsapp' => 'max:255|checkWhatsappContactUnique',
'country' => 'integer|egt:0|requireWith:contacts',
'contacts' => 'require|max:255|requireWithout:email,tel,whatsapp|checkContactUnique',
'content' => 'string',
'status' => 'in:0,1,2',
'r_time' => 'integer|egt:0',
't_time' => 'integer|egt:0'
];
// 提示
protected $message = [
'id.require' => 'ID不能为空',
'id.integer' => 'ID必须是整数',
'id.gt' => 'ID必须大于0',
'order_no.require' => '订单号不能为空',
'order_no.alphaDash'=> '订单号只能包含字母、数字和下划线',
'order_no.length' => '订单号长度必须为36位',
'enter_user_id.integer' => '录入人ID必须是整数',
'enter_user_id.egt' => '录入人ID不能为负数',
'recipient.integer' => '接单人ID必须是整数',
'recipient.egt' => '接单人ID不能为负数',
'quality.require' => '询单等级不能为空',
'quality.in' => '询单等级只能是1、2或3',
'channel.integer' => '渠道编号必须是整数',
'channel.between' => '渠道编号范围0-99',
'email.email' => '邮箱格式不正确',
'email.max' => '邮箱长度不能超过255个字符',
'tel.max' => '电话长度不能超过255个字符',
'whatsapp.max' => 'WhatsApp长度不能超过255个字符',
'country.integer' => '国家代码必须是整数',
'country.egt' => '国家代码不能为负数',
'contacts.require' => '联系人不能为空',
'contacts.max' => '联系人长度不能超过255个字符',
'content.string' => '询单内容必须是字符串',
'status.in' => '状态值只能是0、1或2',
'r_time.integer' => '发布时间必须是整数',
'r_time.egt' => '发布时间不能为负数',
't_time.integer' => '跟踪时间必须是整数',
't_time.egt' => '跟踪时间不能为负数',
'email.checkEmailContactUnique' => '邮箱联系方式已存在',
'tel.checkTelContactUnique' => '电话联系方式已存在',
'whatsapp.checkWhatsappContactUnique' => 'WhatsApp联系方式已存在',
'contacts.requireWithout' => '至少需要提供一种联系方式',
'contacts.checkContactUnique' => '联系人已存在',
'country.requireWith' => '国家必须与联系人一起提供'
];
// 场景
protected $scene = [
'store' => [
'enter_user_id', 'quality', 'channel', 'email', 'tel', 'whatsapp', 'country', 'contacts', 'content'
],
'update' => [
'quality', 'channel', 'email', 'tel', 'whatsapp', 'country', 'contacts', 'content'
],
'release' => [
'status', 'r_time'
],
'allocation' => [
'status', 't_time'
]
];
/**
* 验证联系方式唯一性
*/
protected function checkContactUnique($value, $rule, $data, $field, $title)
{
// 如果是更新操作且联系人未修改,跳过检查
if ($this->currentScene == 'update') {
$original = InquiryLog::find($data['id']);
if ($original && $original->contacts == $value && $original->country == ($data['country'] ?? 0)) {
return true;
}
}
if (empty($data['email']) && empty($data['tel']) && empty($data['whatsapp'])) {
$query = InquiryLog::where('contacts', trim($value))
->where('country', $data['country']);
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
return true;
}
/**
* 验证邮箱联系方式唯一性
*/
protected function checkEmailContactUnique($value, $rule, $data, $field, $title)
{
if (empty($value)) return true;
// 如果是更新操作且邮箱未修改,跳过检查
if ($this->currentScene == 'update' && !empty($data['id'])) {
$original = InquiryLog::find($data['id']);
if ($original && $original->email == $value) {
return true;
}
}
$query = InquiryLog::where('email', trim($value));
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
/**
* 验证电话联系方式唯一性
*/
protected function checkTelContactUnique($value, $rule, $data, $field, $title)
{
if (empty($value)) return true;
// 如果是更新操作且电话未修改,跳过检查
if ($this->currentScene == 'update' && !empty($data['id'])) {
$original = InquiryLog::find($data['id']);
if ($original && $original->tel == $value) {
return true;
}
}
$query = InquiryLog::where('tel', trim($value));
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
/**
* 验证WhatsApp联系方式唯一性
*/
protected function checkWhatsappContactUnique($value, $rule, $data, $field, $title)
{
if (empty($value)) return true;
// 如果是更新操作且WhatsApp未修改,跳过检查
if ($this->currentScene == 'update' && !empty($data['id'])) {
$original = InquiryLog::find($data['id']);
if ($original && $original->whatsapp == $value) {
return true;
}
}
$query = InquiryLog::where('whatsapp', trim($value));
if (!empty($data['id'])) {
$query->where('id', '<>', $data['id']);
}
return !$query->exists();
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ class RoleValidate extends Validate
];
protected $scene = [
'role_create' => ['system_id', 'name', 'desc', 'sort'],
'role_update' => ['id', 'system_id', 'name', 'desc', 'sort', 'status'],
'store' => ['system_id', 'name', 'desc', 'sort'],
'update' => ['id', 'system_id', 'name', 'desc', 'sort', 'status'],
];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class SystemsValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
+64 -15
View File
@@ -5,32 +5,35 @@ namespace app\controller;
use app\common\model\User;
use app\common\model\UserRoles;
use app\common\model\UserSystems;
use app\common\utils\scaffold\Generate;
use support\Request;
use support\Db;
use support\Cache;
use app\inquiry\service\InquiryLogService;
class IndexController
{
protected $noNeedLogin = ['index', 'view', 'perm'];
protected $noNeedLogin = ['index', 'view', 'perm', 'createResource', 'test'];
protected $noNeedHeaderData = ['index', 'view', 'perm', 'createResource', 'test'];
public function index(Request $request)
{
return htmlpage();
}
/**
* 调试飞书
*/
public function view(Request $request)
{
return view('index/qrcode', ['name' => 'webman']);
}
public function json(Request $request)
{
return json(['code' => 0, 'msg' => 'ok']);
}
// 临时增加用户权限
/**
* 临时增加用户权限
*/
public function perm(Request $request)
{
try {
@@ -62,13 +65,59 @@ class IndexController
}
}
// test function
public function test(Request $request)
/**
* 生成资源
*/
public function createResource()
{
$user = User::find(1);
$user->roles()->sync([1]);
$user->systems()->sync([1]);
return json(['code' => 0, 'msg' => 'success']);
$options = [
// 'controller' => [
// 'module' => 'inquiry',
// 'name' => 'country'
// ],
'model' => [
'module' => 'common',
'name' => 'crm_customer'
],
// 'service' => [
// 'module' => 'inquiry',
// 'name' => 'country'
// ],
// 'logic' => [
// 'module' => 'inquiry',
// 'name' => 'country'
// ],
// 'validate' => [
// 'module' => 'inquiry',
// 'name' => 'country'
// ],
'table' => 'crm_customer'
];
$result = (new Generate($options))->doGenerate();
return $result;
}
public function test()
{
// $res = ['id'=>25714];
// \Webman\RedisQueue\Redis::send('inquiry-to-crm', $res);
// $res = [
// 'id'=>25717,
// 'category'=>'inquiry',
// 'scene'=>'inquiry-person-allocation',
// 'receive_type'=>'person'
// ];
// $res = [
// 'id'=>1,
// 'category'=>'crm',
// 'scene'=>'crm-person-follow-up',
// 'receive_type'=>'person'
// ];
// \Webman\RedisQueue\Redis::send('feishu-message', $res);
return 'success';
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace app\crm\controller;
use support\Request;
use support\Response;
use support\exception\BusinessException;
use app\common\traits\ResponseTrait;
use think\Validate;
abstract class BaseController
{
use ResponseTrait;
protected mixed $validator = null;
/**
* 验证数据
*
* @param Validate $validator 验证器实例
* @param array $data 待验证的数据
* @param string $scene 验证场景
* @return array 验证后的数据
* @throws BusinessException 验证失败时抛出业务异常
*/
public function validate(Validate $validator, array $data, string $scene = ''): array
{
// 如果指定了验证场景,并且验证器支持场景方法,则设置验证场景
if ($scene && method_exists($validator, 'scene')) {
$validator->scene($scene);
}
// 执行数据验证
if (!$validator->check($data)) {
// 验证失败,抛出业务异常,异常信息为验证器返回的错误信息
throw new BusinessException($validator->getError());
}
// 验证成功,返回原始数据
return $data;
}
public function page($result){
$current = $result->currentPage();
$total = $result->total();
$size = $result->perPage();
$result = $result->toArray();
$result['current'] = $current;
$result['total'] = $total;
$result['size'] = $size;
return $result;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace app\controller;
use app\crm\controller\BaseController;
use app\crm\model\CarType;
use app\crm\model\Customer;
use app\crm\utils\Util;
use PDO;
use support\Db;
use support\Redis;
use support\Request;
class CarTypeController extends BaseController
{
public function getAll(Request $request)
{
$list = CarType::getAll();
if($request->user['language'] != 'zh_CN'){
foreach ($list as &$value) {
$value['name'] = $value['name_en'];
}
}elseif ($request->user['language'] == 'ru'){
foreach ($list as &$value) {
$value['name'] = $value['name_ru'];
}
}
return $this->success($list);
}
}
+530
View File
@@ -0,0 +1,530 @@
<?php
namespace app\crm\controller;
use app\common\model\Corp;
use app\common\model\User;
use app\crm\model\Channel;
use app\crm\model\Country;
use app\crm\model\Customer;
use app\crm\model\CustomerHis;
use app\crm\model\Follow;
use app\crm\service\CustomerService;
use app\crm\utils\Util;
use config\constants\Constants;
use support\Request;
class CustomerController extends BaseController
{
/**
* 不需要头信息的方法
*/
protected $noNeedHeader = [];
protected $noNeedLogin = ['getChannel', 'getCountry'];
/**
* 客户分级
* @param Request $request
* @return \support\Response
*/
public function getGrade(Request $request)
{
$grade = [];
$gradeConstants = Constants::GRADE_EN;
if('zh_CN' == 'zh_CN'){
$gradeConstants = Constants::GRADE_ZH;
}elseif ('zh_CN' == 'ru'){
$gradeConstants = Constants::GRADE_RU;
}
foreach ($gradeConstants as $key => $value) {
$grade[] = [
'id' => $key,
'name' => $value,
];
}
return $this->success($grade);
}
/**
* 商机
* @param Request $request
* @return \support\Response
*/
public function getMagnitude(Request $request)
{
$grade = [];
$magnitudeConstants = Constants::MAGNITUDE_EN;
if('zh_CN' == 'zh_CN'){
$magnitudeConstants = Constants::MAGNITUDE;
}elseif ('zh_CN' == 'ru'){
$magnitudeConstants = Constants::MAGNITUDE_RU;
}
foreach ($magnitudeConstants as $key => $value) {
$grade[] = [
'id' => $key,
'name' => $value,
];
}
return $this->success($grade);
}
/**
* 客户渠道
* @param Request $request
* @return \support\Response
*/
public function getChannel(Request $request)
{
$list = Channel::getAll();
if('zh_CN' != 'zh_CN'){
foreach ($list as &$value) {
$value['name'] = $value['name_en'];
}
}elseif ('zh_CN' == 'ru'){
foreach ($list as &$value) {
$value['name'] = $value['name_ru'];
}
}
return $this->success($list);
}
/**
* 国家
* @param Request $request
* @return \support\Response
*/
public function getCountry(Request $request)
{
$list = Country::getAll();
if('zh_CN' != 'zh_CN'){
foreach ($list as &$value) {
$value['name'] = $value['name_en'];
}
}elseif ('zh_CN' == 'ru'){
foreach ($list as &$value) {
$value['name'] = $value['name_ru'];
}
}
return $this->success($list);
}
/**
* 待跟进客户列表
* @param Request $request
* @return \support\Response
*/
public function waitFollow(Request $request)
{
$userId = $request->user->uid;
$filter = Util::getParam($request, 'filter');
$page = $request->input('current', 1);
$perPage = $request->input('size', 10);
$where = [
['user_id', '=', $userId],
['corp_abbr', '=', $request->basic_data['corp_abbr']],
['follow_next', '<>', 0],
];
if($filter == 0){
$where[] = ['follow_next', '<=', time() + 3600 * 24];
} elseif ($filter == 1) {
$where[] = ['follow_next', '<=', time() + 3600 * 24 * 3];
} elseif ($filter == 2) {
$where[] = ['follow_next', '<=', time() + 3600 * 24 * 7];
}
$customer = Customer::where($where)
->whereNotNull('follow_next')
->orderBy('follow_next')
->paginate($perPage, ['*'], 'page', $page);
foreach ($customer as &$item) {
$item['follow_remaining'] = $item->follow_next-time();
$item['follow_next'] = date('Y-m-d H:i:s',$item->follow_next);
}
return $this->success($this->page($customer));
}
/**
* 客户列表
* @param Request $request
* @return \support\Response
*/
public function list(Request $request)
{
try {
$param = $request->all();
$param['user_id'] = $request->user->uid;
$param['corp_abbr'] = $request->basic_data['corp_abbr'];
$result = (new CustomerService())->list($param);
return $this->success($this->page($result));
} catch (\Throwable $e) {
return $this->error($e->getMessage().$e->getFile().$e->getLine());
}
}
/**
* 客户恢复
* @param Request $request
* @return \support\Response
*/
public function restore(Request $request)
{
$id = Util::getParam($request,'id', '');
if (Util::isEmpty($id)) {
return $this->error(trans('id_empty'));
}
$userId = $request->user->uid;
$where = [
'id' => $id,
'user_id' => $userId,
];
$res = Customer::where($where)->restore();
if($res){
return $this->success($res, trans('success'));
}else{
return $this->error(trans('fail'));
}
}
/**
* 客户详情
* @param Request $request
* @return \support\Response
*/
public function detail(Request $request)
{
$id = Util::getParam($request, 'id');
if (Util::isEmpty($id)) {
return $this->error('id' . trans('not_null'));
}
// $res = Customer::with('tags')->select('id', 'user_id', 'grade', 'channel_id', 'car_type_id', 'country_id', 'magnitude', 'email', 'phone', 'WhatsApp', 'contacts', 'describe', 'address', 'lat', 'lng', 'follow_next', 'follow_remark', 'sex')
// ->where('id', $id)
// ->first();
$res = (new CustomerService)->detail($request->all());
if ($res) {
$res->toArray();
} else {
return $this->error('记录不存在');
}
$res = CustomerService::getCustomer($res, 'zh_CN', true);
return $this->success($res);
}
/**
* 客户录入
* @param Request $request
* @return \support\Response
*/
public function save(Request $request)
{
$id = Util::getParam($request, 'id');
$channel_id = Util::getParam($request, 'channel_id');
if (Util::isEmpty($channel_id)) {
return $this->error(trans('channel_not_null'));
}
$country_id = Util::getParam($request, 'country_id', 0);
if (Util::isEmpty($country_id)) {
// return $this->error('country_id'.trans('not_null'));
}
// $car_type_id = Util::getParam($request, 'car_type_id');
// if (Util::isEmpty($car_type_id)) {
// return $this->error(trans('car_type_not_null'));
// }
$contacts = Util::getParam($request, 'contacts');
$email = Util::getParam($request, 'email');
$phone = Util::getParam($request, 'phone');
if (Util::isEmpty($contacts) && Util::isEmpty($email) && Util::isEmpty($phone)) {
return $this->error(trans('contacts_not_null'));
}
$corpInfo = Corp::where('abbr', $request->basic_data['corp_abbr'])->first();
$corpId = $corpInfo->id;
$data = [
'user_id' => $request->user->uid,
'corp_abbr' => $corpId,
'grade' => Util::getParam($request, 'grade', 0),
'channel_id' => $channel_id,
'country_id' => $country_id,
// 'car_type_id' => $car_type_id,
'magnitude' => Util::getParam($request, 'magnitude', 0),
'email' => $email,
'phone' => $phone,
'WhatsApp' => Util::getParam($request, 'WhatsApp', ''),
'contacts' => $contacts,
'describe' => Util::getParam($request, 'describe'),
'lat' => Util::getParam($request, 'lat', 0),
'lng' => Util::getParam($request, 'lng', 0),
'address' => Util::getParam($request, 'address', ''),
'sex' => Util::getParam($request, 'sex', 0),
];
if (Util::isEmpty($id)) {
$res = Customer::create($data);
} else {
unset($data['grade']);
$res = Customer::where('id', $id)
->update($data);
// Log::info('update', ['res' => $res]);
(!$res) ?: $res = Customer::find($id)->toArray();
}
if ($res) {
return $this->success($res, trans('save_success'));
} else {
return $this->error(trans('save_fail'));
}
}
/**
* 更新下次跟进时间
* @param Request $request
* @return \support\Response
*/
public function followNext(Request $request)
{
$id = Util::getParam($request, 'id');
if (Util::isEmpty($id)) {
return $this->error('id' . trans('not_null'));
}
$data = [
'follow_next' => Util::getParam($request, 'follow_next', 0),
'follow_remark' => Util::getParam($request, 'follow_remark', 0),
'warn' => 0,
];
$res = Customer::where('id', $id)
->update($data);
if ($res) {
return $this->success($res, trans('save_success'));
} else {
return $this->error(trans('save_fail'));
}
}
/**
* 删除客户
* @param Request $request
* @return \support\Response
*/
public function delete(Request $request)
{
$id = Util::getParam($request, 'id');
$force = Util::getParam($request, 'force','');
$userId = $request->user->uid;
$where = [
'id' => $id,
'user_id' => $userId,
];
$customer = Customer::where($where)->withTrashed()->first();
if(empty($customer)){
return $this->error( trans('fail'));
}
if($force == 'force'){
$res = $customer->forceDelete();
}else{
$res = $customer->delete();
}
if ($res) {
return $this->success($res, trans('success'));
} else {
return $this->error(trans('fail'));
}
}
/**
* 统计客户
* @param Request $request
* @return \support\Response
*/
public function statistics(Request $request)
{
$created = Util::getParam($request, 'created');
if (Util::isEmpty($created)) {
return $this->error('created' . trans('not_null'));
}
$userId = $request->user->uid;
//$created_at = $request->user['created_at'];
$created_at = User::find($userId)->value('created_at');
$created_at = strtotime($created_at);
$day = ceil((time() - $created_at + 1) / 60 / 60 / 24);
$where[] = ['user_id', '=', $userId];
//$where[] = ['corp_abbr', '=', $request->basic_data['corp_abbr']];
$start = strtotime($created . '-01');
$year = date('Y', $start);
$month = date('n', $start); //获取月份
$end = mktime(00, 00, 00, $month + 1, 1, $year);
$where[] = ['created_at', '>=', $start];
$where[] = ['created_at', '<', $end];
$customer = Customer::where($where)->count();
//初步接触
$whereGrade = $where;
$whereGrade[] = ['grade', '<=', 2];
$customerInit = Customer::where($whereGrade)->count();
//跟进中
$customeroFllow = Customer::where($where)->whereIn('grade', [3, 4])->count();
//已成交
$whereGrade = $where;
$whereGrade[] = ['grade', '=', 5];
$customerDeal = Customer::where($whereGrade)->count();
//拜访数
$whereVisit = $where;
$whereVisit[] = ['type', '=', 0];
$visit = Follow::where($whereVisit)->count();
//跟进数
$whereVisit = $where;
$whereVisit[] = ['type', '=', 1];
$follow = Follow::where($whereVisit)->count();
$res = [
'customer' => $customer, //客户数
'visit' => $visit, //拜访
'follow' => $follow, //跟进
'day' => $day, //注册天数
'customer_init' => $customerInit, //初步接触
'customero_fllow' => $customeroFllow, //跟进中
'customer_deal' => $customerDeal, //已成交
];
if ($res) {
return $this->success($res, trans('success'));
} else {
return $this->error(trans('fail'));
}
}
/**
* 领取公海客户
* @param Request $request
* @return \support\Response
*/
public function claim(Request $request)
{
$customerId = Util::getParam($request, 'customer_id');
$userId = $request->user->uid;
if (Util::isEmpty($customerId)) {
return $this->error(trans('customer_id').trans('not_null'));
}
$data = [
'user_id' => $request->user->uid
];
$customer= Customer::withTrashed()->find($customerId);
if(empty($customer) || $customer->user_id != 0){
return $this->error( trans('claim_fail'));
}
$customer->restore();
$res = $customer->update($data);
//记录客户关系变化
$his = CustomerHis::create([
'customer_id' => $customerId,
'user_his' => 0,
'user_id' => $userId,
]);
if ($res) {
return $this->success($res, trans('success'));
} else {
return $this->error(trans('fail'));
}
}
/**
* 批量转让
* @param Request $request
* @return \support\Response
*/
public function cede(Request $request)
{
$customerId = Util::getParam($request, 'customer_id');
$user_sn = Util::getParam($request, 'user_sn');
$userId = $request->user->uid;
if (Util::isEmpty($user_sn)) {
return $this->error('ID'.trans('not_null'));
}
$user = User::where(['sn' => $user_sn])->first();
if (Util::isEmpty($user)) {
return $this->error( trans('user_none'));
}
if(Util::isEmpty($customerId)){
$data = [
'user_id' => $userId
];
$query= Customer::where($data);
}else{
$query= Customer::whereIn('id',explode(',',$customerId));
}
$customer = $query->get();
foreach ($customer as $item) {
//记录客户关系变化
$his = CustomerHis::create([
'customer_id' => $item->id,
'user_his' => $item->user_id,
'user_id' => $user['id'],
]);
}
$res = $query->update(['user_id' => $user->id]);
if ($res) {
return $this->success($res, trans('success'));
} else {
return $this->error(trans('fail'));
}
}
/**
* 跟进状态
* @param Request $request
* @return \support\Response
*/
public function grade(Request $request)
{
$id = Util::getParam($request, 'id');
$grade = Util::getParam($request, 'grade');
if (Util::isEmpty($id)) {
return $this->error( 'id' . trans('not_null'));
}
if (Util::isEmpty($id)) {
return $this->error( 'grade' . trans('not_null'));
}
$data = [
'grade' => $grade,
];
$res = Customer::where('id', $id)
->update($data);
if ($res) {
return $this->success($res, trans('save_success'));
} else {
return $this->error(trans('save_fail'));
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace app\crm\controller;
use app\common\model\Corp;
use app\crm\model\Customer;
use app\crm\model\Follow;
use app\crm\service\CustomerService;
use app\crm\service\FollowService;
use app\crm\utils\Util;
use app\crm\validate\FollowValidate;
use config\constants\Constants;
use support\Request;
class FollowController extends BaseController
{
/**
* 客户分级
* @param Request $request
* @return \support\Response
*/
public function getFollowType(Request $request)
{
$grade = [];
foreach (Constants::FOLLOW as $key => $value) {
$grade[] = [
'status' => $key,
'text' => $value,
];
}
return $this->success($grade);
}
/**
* 跟进列表
* @param Request $request
* @return \support\Response
*/
public function list(Request $request)
{
try {
$data = $this->validate(new FollowValidate(), $request->get(), 'list');
$request->setGet('corp_abbr', $request->basic_data['corp_abbr']);
$result = (new FollowService())->list($request->all(), $request->user->uid);
return $this->success($result);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 跟进详情
* @param Request $request
* @return \support\Response
*/
public function detail(Request $request)
{
//跟进id
$id = Util::getParam($request, 'id');
if (Util::isEmpty($id)) {
return $this->error('id' . trans('not_null'));
}
$res = Follow::select('id', 'user_id', 'customer_id', 'type', 'picture', 'remark', 'audio', 'audio_text', 'lat', 'lng', 'address')
->where('id', $id)
->first();
if ($res) {
$res->toArray();
} else {
return $this->error('记录不存在');
}
$res = FollowService::getFollow($res);
return $this->success($res);
}
/**
* 客户录入
* @param Request $request
* @return \support\Response
*/
public function save(Request $request)
{
$customer_id = Util::getParam($request, 'customer_id');
if (Util::isEmpty($customer_id)) {
return $this->error('customer_id' . trans('not_null'));
}
$type = Util::getParam($request, 'type');
if (Util::isEmpty($type)) {
return $this->error('type' . trans('not_null'));
}
$audio_text = Util::getParam($request, 'audio_text', '');
$picture = Util::getParam($request, 'picture');
$remark = Util::getParam($request,'remark');
if(Util::isEmpty($picture) && Util::isEmpty($audio_text) && Util::isEmpty($remark)){
return $this->error(trans('follow_not_null'));
}
$corpInfo = Corp::where('abbr', $request->basic_data['corp_abbr'])->first();
$corpId = $corpInfo->id;
$data = [
'user_id' => $request->user->uid,
'corp_abbr' => $corpId,
'customer_id' => $customer_id,
'type' => $type,
'picture' => $picture,
'remark' => Util::getParam($request, 'remark', ''),
'audio' => Util::getParam($request, 'audio', ''),
'audio_text' => $audio_text,
'lat' => Util::getParam($request, 'lat', 0),
'lng' => Util::getParam($request, 'lng', 0),
'address' => Util::getParam($request, 'address'),
'follow_time' => strtotime(Util::getParam($request, 'firstFollowtime', 0)),
];
$res = Follow::create($data);
$customer = Customer::find($customer_id);
$customer->follow_last = strtotime($res->created_at);
$customer->follow_next = null;
$customer->warn = 0;
$customer->save();
$follow_next = Util::getParam($request, 'follow_next', 0);
$follow_remark = Util::getParam($request, 'follow_remark', 0);
if ($res && (!empty($follow_next))) {
$data = [
'follow_next' => $follow_next,
'follow_remark' => $follow_remark,
'warn' => 0,
];
$res = Customer::where('id', $customer_id)
->update($data);
}
if ($res) {
return $this->success($res, trans('save_success'));
} else {
return $this->error(trans('save_fail'));
}
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace app\crm\controller;
use app\crm\controller\BaseController;
use app\crm\model\Customer;
use app\crm\model\CustomerTag;
use app\crm\model\TagGroup;
use app\crm\model\Tag;
use app\crm\utils\Util;
use config\constants\Constants;
use support\Request;
class TagGroupController extends BaseController
{
// 列表
public function list(Request $request)
{
$uid = $request->user->uid;
$scene = $request->input("scene");
$list = TagGroup::with('tags')->get();
$languageArr = Constants::LANGUAGE;
$language = 'zh_CN';
foreach ($list as &$item) {
$item['names'] = unserialize($item['names']);
if (in_array($language, $languageArr)) {
$item['name'] = $item['names'][$language];
}
foreach ($item['tags'] as &$tag) {
$tag['names'] = unserialize($tag['names']);
if (in_array($language, $languageArr)) {
$tag['name'] = $tag['names'][$language];
}
if($scene == 'customers'){
// 统计每个标签关联的客户数量,增加user_id条件
$tag['count'] = $tag->customers()->where('user_id', $uid)->count();
}
}
}
return $this->success($list);
}
/**
* 更新客户标签关系
* @param Request $request
* @return \support\Response
*/
public function updatePivot(Request $request)
{
$customerId = Util::getParam($request, 'customer_id');
$groupId = Util::getParam($request, 'group_id');
$tagId = Util::getParam($request, 'tag_id');
if (Util::isEmpty($customerId)) {
return $this->error( trans('customer_id') . trans('not_null'));
}
if (Util::isEmpty($groupId)) {
return $this->error( trans('tag_group_id') . trans('not_null'));
}
try {
if(empty($tagId)){
$res = CustomerTag::where(['customer_id' => $customerId, 'group_id' => $groupId])->delete();
}else{
$res = CustomerTag::updateOrCreate(['customer_id' => $customerId, 'group_id' => $groupId], ['tag_id' => $tagId]);
}
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
if($res){
return $this->success($res, trans('success'));
}else{
return $this->error( trans('fail'));
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace app\crm\logic;
use app\crm\model\Customer;
class CustomerLogic
{
public function find(int $id)
{
$customer = Customer::find($id);
return $customer;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace app\crm\logic;
class FollowLogic
{
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace app\crm\model;
use support\Model;
class CarType extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'car_type';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
public static function getAll()
{
$list = self::get()->select('id','name','name_en','status')->where('status',0)->toArray();
return $list;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace app\crm\model;
use support\Model;
class Channel extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_channel';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id','created_at','updated_at'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $casts = [
// 'updated_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
// 'created_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
'updated_at' => 'timestamp', // 指定序列化格式
'created_at' => 'timestamp', // 指定序列化格式
];
public static function getAll()
{
$list = self::get()->select('id','name','name_en','status')->where('status',1)->toArray();
return $list;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace app\crm\model;
use support\Model;
class Country extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'country';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
public static function getAll()
{
$list = self::select('id','cn_addr','en_addr')->get();
return $list;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace app\crm\model;
use app\crm\utils\Util;
use config\constants\Constants;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use support\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use app\common\model\User;
class Customer extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_customer';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id','created_at','updated_at'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
use SoftDeletes;
protected $dates = ['deleted_at'];
protected $casts = [
'updated_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
'created_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
];
/**
* 重新格式化时间
* @param DateTimeInterface $date
* @return string
*/
protected function serializeDate(DateTimeInterface $date): string
{
return $date->format(Carbon::parse($date)->toDateTimeString());
}
/**
* 客户的标签
*/
public function tags()
{
return $this->belongsToMany(Tag::class, 'crm_customer_tag', 'customer_id', 'tag_id')
->withTimestamps(); // 如果需要记录关联时间
}
// 定义与 变更历史 的一对多关系
public function his()
{
return $this->hasMany(CustomerHis::class, 'customer_id', 'id');
}
// 定义反向多对一关联
public function user()
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace app\crm\model;
use app\crm\utils\Util;
use config\constants\Constants;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use support\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CustomerHis extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_customer_his';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id','created_at'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
// 'created_at' => 'timestamp', // 指定序列化格式
];
/**
* 重新格式化时间
* @param DateTimeInterface $date
* @return string
*/
protected function serializeDate(DateTimeInterface $date): string
{
return $date->format(Carbon::parse($date)->toDateTimeString());
}
// 定义反向多对一关联
public function customer()
{
return $this->belongsTo(Customer::class, 'customer_id', 'id');
}
// 禁用 updated_at 的自动更新
public function setUpdatedAt($value)
{
return $this; // 不做任何操作
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace app\crm\model;
use app\crm\utils\Util;
use support\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CustomerTag extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_customer_tag';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id', 'created_at'];
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace app\crm\model;
use app\service\OssService;
use app\crm\utils\Util;
use config\constants\Constants;
use support\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Follow extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_follow';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id','created_at','updated_at'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dates = ['created_at', 'updated_at', 'follow_time'];
protected $casts = [
'updated_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
'created_at' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
'follow_time' => 'datetime:Y-m-d H:i:s', // 指定序列化格式
// 'updated_at' => 'timestamp', // 指定序列化格式
// 'created_at' => 'timestamp', // 指定序列化格式
];
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace app\crm\model;
use app\util\Util;
use config\constants\Constants;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use support\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Tag extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_tag';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id','created_at','updated_at'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
use SoftDeletes;
protected $dates = ['deleted_at'];
// 定义反向多对一关联
public function group()
{
return $this->belongsTo(TagGroup::class, 'group_id', 'id');
}
public function customers()
{
return $this->belongsToMany(Customer::class, 'crm_customer_tag', 'tag_id', 'customer_id');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace app\crm\model;
use app\crm\utils\Util;
use config\constants\Constants;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use support\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class TagGroup extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'crm_tag_group';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
//设置黑名单
protected $guarded = ['id','created_at','updated_at'];
// 指定日期字段存储格式为时间戳
protected $dateFormat = 'U';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
use SoftDeletes;
protected $dates = ['deleted_at'];
// 定义与 Tag 的一对多关系
public function tags()
{
return $this->hasMany(Tag::class, 'group_id', 'id');
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace app\crm\service;
use app\crm\logic\CustomerLogic;
use app\crm\model\CarType;
use app\crm\model\Channel;
use app\crm\model\Country;
use app\crm\model\Customer;
use app\crm\model\Follow;
use app\crm\utils\Util;
use config\constants\Constants;
class CustomerService
{
public function list($params)
{
// $page = $request->input('page', 1);
// $perPage = $request->input('per_page', 10);
$perPage = $params['size'] ?? 10;
$page = $params['current'] ?? 1;
$keyword = $params['keyword'] ?? '';
$recycle = $params['recycle'] ?? '';
$follow = $params['follow'] ?? '';
$follow_last = $params['follow_last'] ?? '';
$tagId = $params['tag_id'] ?? [];
$where = [];
$user_id =$params['user_id'];
$seas = $params['seas'] ?? '';
if($seas == 'seas'){
$where[] = ['user_id', 0];
// }elseif($recycle == 'recycle'){
// $where[] = ['user_id', $user_id];
}else{
$where[] = ['user_id', $user_id];
}
$grade = [];
if (!Util::isEmpty($follow) && $follow == 0) {
$where[] = ['grade', '<=', 2];
} elseif (!Util::isEmpty($follow) && $follow == 1) {
$grade = [3, 4];
} elseif (!Util::isEmpty($follow) && $follow == 2) {
$where[] = ['grade', '=', 5];
}
if (!Util::isEmpty($follow_last) && $follow_last == 0) {
$where[] = ['follow_last', '>', strtotime('-1 day')];
} elseif (!Util::isEmpty($follow_last) && $follow_last == 1) {
$where[] = ['follow_last', '>', strtotime('-3 day')];
} elseif (!Util::isEmpty($follow_last) && $follow_last == 2) {
$where[] = ['follow_last', '>', strtotime('-7 day')];
}
$query = Customer::where($where);
if(!empty($tagId)){
//查询有$tagId 里任意一个标签的客户
//$query->whereHas('tags', function($query) use ($tagId){
// $query->whereIn ('tag.id', $tagId);
//});
//查询有$tagId 里所有标签的客户
foreach ($tagId as $id) {
$query->whereHas('tags', function($query) use ($id){
$query->where('tag_id', $id);
});
}
}
if (!Util::isEmpty($keyword)) {
$query->where(function ($query) use ($keyword) {
$query->where('contacts', 'like', "%{$keyword}%")
->orWhere('phone', 'like', "%{$keyword}%");
});
}
if (!Util::isEmpty($grade)) {
$query->whereIn('grade', $grade);
}
if ($recycle == 'recycle') {
$query->onlyTrashed();
}
if ($seas == 'seas') {
$query->withTrashed();
}
$customer = $query->paginate($perPage, ['*'], 'page', $page);
foreach ($customer as &$item) {
$item = $this->getCustomer($item);
if($seas == 'seas'){
$item['phone'] = Util::mask($item['phone']);
$item['email'] = Util::mask($item['email']);
$item['WhatsApp'] = Util::mask($item['WhatsApp']);
$item['seas_time'] = time()-(empty($item['deleted_at'])?strtotime($item['updated_at']):strtotime($item['deleted_at']));
}
}
unset($item);
return $customer;
}
/**
* @param $customer
* @param $request
* @param $tag 是否返回客户标签
* @return mixed
*/
public static function getCustomer($customer, $language = 'zh_CN', $tag = false)
{
$customer['grade_txt'] = Util::language($language, Constants::GRADE_EN[$customer['grade']], Constants::GRADE_ZH[$customer['grade']], Constants::GRADE_RU[$customer['grade']]);
$customer['magnitude_txt'] = Util::language($language, Constants::MAGNITUDE_EN[$customer['magnitude']], Constants::MAGNITUDE[$customer['magnitude']], Constants::MAGNITUDE_RU[$customer['magnitude']]);
$customer['sex_txt'] = Util::language($language, Constants::SEX_EN[$customer['sex']], Constants::SEX[$customer['sex']], Constants::SEX_RU[$customer['sex']]);
if ($customer['grade']<=2) {
$customer['follow'] = 0;
$customer['follow_txt'] = Util::language($language,'Following','跟进中','Продолжение');
}elseif ($customer['grade']<5) {
$customer['follow'] = 1;
$customer['follow_txt'] = Util::language($language,'transacted','已成交','Транзакция завершена');
}else{
$customer['follow'] = 2;
$customer['follow_txt'] = Util::language($language,'lost','已流失','Потерянный');
}
$where = [
'customer_id' => $customer['id']
];
$begin_follow = Follow::select('id','created_at','updated_at')
->where($where)
->min('created_at');
$customer['begin_follow'] = Util::timeTotDate($begin_follow);
// $customer['created_at'] = Util::timeTotDate($customer['created_at']);
// $customer['updated_at'] = Util::timeTotDate($customer['updated_at']);
$customer = Util::formatDate($customer,['follow_last']);
$nameLang = $language=='en'?'name_en':($language=='ru'?'name_ru':'name');
$countryLang = $language=='en'?'en_addr':'cn_addr';
// $customer['car_type_name'] = empty($customer['car_type_id'])?'':CarType::where(['id'=>$customer['car_type_id']])->value($nameLang);
$customer['country_name'] = empty($customer['country_id'])?'':Country::where(['id'=>$customer['country_id']])->value($countryLang);
$customer['channel_name'] = empty($customer['channel_id'])?'':Channel::where(['id'=>$customer['channel_id']])->value($nameLang);
// $customer['last_follow'] = $last_follow;
if ($tag) {
foreach ($customer['tags'] as &$tag) {
$tag['names'] = unserialize($tag['names']);
$tag['name'] = $tag['names'][$language];
}
}
return $customer;
}
public function detail($params)
{
$id =$params['id'];
$customer = Customer::with('tags')->select('id', 'user_id', 'grade', 'channel_id', 'car_type_id', 'country_id', 'magnitude', 'email', 'phone', 'WhatsApp', 'contacts', 'describe', 'address', 'lat', 'lng', 'follow_next', 'follow_remark', 'sex')
->where('id', $id)
->first();
return $customer;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace app\crm\service;
use app\common\service\OssService;
use app\crm\model\Follow;
use app\crm\utils\Util;
use config\constants\Constants;
class FollowService
{
public function list($param, $userId)
{
// $page = $request->input('page', 1);
// $perPage = $request->input('per_page', 10);
//客户id
$customer_id = $param['customer_id'];
$corp_abbr = $param['corp_abbr'];
$where = [];
$where['customer_id'] = $customer_id;
// $where['corp_abbr'] = $corp_abbr;
//用户角色
//跟进id
$id = $param['id'] ?? null;
if (!empty($id)) {
$where['id'] = $id;
}
// 执行分页查询
$data = Follow::where($where)
->orderBy('id', 'desc')->get();
// ->paginate($perPage, ['*'], 'page', $page);
// $host = $request->host();
// $url = 'http:'.$request->url();
foreach ($data as &$value) {
$value = self::getFollow($value);
}
return $data;
}
public static function getFollow($follow)
{
$follow['type_txt'] = Constants::FOLLOW[$follow['type']];
$follow['firstFollowtime'] = $follow['follow_time'];
$oss_picture = '';
if (!empty($follow['picture'])) {
$ossservice = new OssService();
$oss_picture = $ossservice->generatePrivateUrl($follow['picture'], 86400);
}
$follow['picture_full'] = $oss_picture;
// $follow = Util::formatDate($follow);
return $follow;
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace app\crm\utils;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use support\exception\BusinessException;
use support\Log;
use support\Request;
class Util
{
/**
* 参数是否为空
*/
public static function isEmpty($param)
{
if(!empty($param) || $param == 0){
$result = false;
}else{
$result = true;
}
return $result;
}
/**
* 参数是否为空
*/
public static function getParam(Request $request,$param,$default='')
{
$param = $request->input($param,$default);
if(empty($param) && $default!==''){
$param = $default;
}
return $param;
}
/**
* 失败
*/
public static function fail($data = [],$message = '', $code = -1)
{
$result = [
'code' => $code,
'message' => $message,
'data' => $data
];
return json($result);
}
/**
* 成功
*/
public static function success($data = [],$message = '', $code = 0)
{
$result = [
'code' => $code,
'message' => $message,
'data' => $data
];
return json($result);
}
/**
* 分页查询返回
*/
public static function page($data,$message = '', $code = 0)
{
$result = [
'code' => $code,
'message' => $message
];
$result['data'] = [
'list' => $data->items(), // 当前页数据
'total' => $data->total(), // 总记录数
'current_page' => $data->currentPage(),
'per_page' => $data->perPage(),
'last_page' => $data->lastPage()
];
return json($result);
}
/**
* 日期格式转化
*/
public static function timeTotDate($time = 0, $format = 'Y-m-d H:i:s')
{
if (empty($time)) {
return '';
}
return date($format, $time);
}
/**
* 格式化创建时间
*/
public static function formatDate($value,$key = ['created_at','updated_at'])
{
foreach ($key as $k){
$value[$k]= self::timeTotDate($value[$k]);
}
return $value;
}
/**
* 格式化创建时间
*/
public static function language($language,$en,$zh_CN,$ru)
{
if ($language == 'zh_CN'){
return $zh_CN;
}elseif ($language == 'ru'){
return $ru;
}
return $en;
}
public static function mask($str)
{
if(empty($str)){
return '';
}
return substr($str, 0, 3) . '****' . substr($str, -4);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\crm\validate;
use think\Validate;
class FollowValidate extends Validate
{
protected $rule = [
'customer_id' => 'require',
];
protected $message = [
'customer_id.require' => '用户id不能为空',
];
protected $scene = [
'list' => ['customer_id'],
];
}
+9
View File
@@ -66,6 +66,15 @@ if (!function_exists('getConfig')) {
}
}
/*
* 询单号生成
*/
if (!function_exists('orderNum')) {
function orderNum() {
return "SN".date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
}
}
/*
* 调试用
*/
@@ -0,0 +1,33 @@
<?php
namespace app\inquiry\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\inquiry\service\CountryService;
class CountryController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new CountryService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,220 @@
<?php
namespace app\inquiry\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\inquiry\service\InquiryLogService;
use support\Request;
class InquiryLogController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new InquiryLogService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
$data['countdown'] = time()-1;
if (isset($data['r_time'])) {
$countdown = 60 * 60;
$data['countdown'] = (time() - $data['r_time']) < $countdown ? $data['r_time'] + (60*60) : time()-1;
}
if (isset($data['created_at'])) {
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
}
if (isset($data['updated_at'])) {
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
}
if (isset($data['r_time'])) {
$data['r_time'] = $data['r_time'] > 0 ? date('Y-m-d H:i:s', $data['r_time']) : 0;
}
if (isset($data['t_time'])) {
$data['t_time'] = $data['t_time'] > 0 ? date('Y-m-d H:i:s', $data['t_time']) : 0;
}
if (isset($data['quality'])) {
$inquiryLogService = new InquiryLogService();
$data['quality'] = $inquiryLogService->qualityName($data['quality']);
}
// var_dump($data);
return $data;
}
/**
* 发布
*
* @param Request $request
* @return mixed
*/
public function release(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'release');
$res = $this->service->serviceRelease($request, $data);
return $this->success([], '发布成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 分配
*
* @param Request $request
* @return mixed
*/
public function allocation(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'allocation');
$res = $this->service->serviceAllocation($request, $data);
return $this->success([], '分配成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
// 导出
// public function export(Request $request)
// {
// if ($request->method() == "POST") {
// $data = $request->all();
// $where = [];
// if (isset($data['log_lusers']) && $data['log_lusers']) {
// $where[] = ['enter_user_id', '=', $data['log_lusers']];
// }
// if (isset($data['log_gusers']) && $data['log_gusers']) {
// $where[] = ['recipient', '=', $data['log_gusers']];
// }
// if (isset($data['log_country']) && $data['log_country']) {
// $where[] = ['log_country', '=', $data['log_country']];
// }
// if (isset($data['log_channel']) && $data['log_channel']) {
// $where[] = ['log_type', '=', $data['log_channel']];
// }
// if (isset($data['log_times']) && $data['log_times']) {
// $times = explode(' - ', $data["log_times"]);
// $specificDayStart = trim($times[0]);
// $specificDayEnd = trim($times[1]);
// $where[] = [
// "c_time", ">", strtotime($specificDayStart)
// ];
// $where[] = [
// "c_time", "<=", strtotime($specificDayEnd)
// ];
// }
// // 查询
// $results = InquiryLogModel::with(['enterUser', 'recipientUser', 'country'])
// ->where($where)
// ->get()
// ->map(function ($log) use($data) {
// $arr = [];
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['订单号'] = $log->order_no;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['录入人'] = $log->enterUser->name ?? null;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['接单人'] = $log->recipientUser->name ?? null;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['等级'] = $log->log_quality;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['邮箱'] = $log->log_contact_type;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['电话'] = $log->log_contact_tel;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['WhatsApp'] = $log->log_contact_whatsapp;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['联系人'] = $log->log_contacts;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['国家(中文)'] = $log->country->cn_addr ?? null;
// }
// if (isset($data['hsl_order_no']) && $data['hsl_order_no']) {
// $arr['国家(英文)'] = $log->country->en_full_name ?? null;
// }
// if (isset($data['hsl_r_time']) && $data['hsl_r_time']) {
// $arr['发单时间'] = $log->r_time ? date('Y-m-d H:i:s', $log->r_time) : null;
// }
// if (isset($data['hsl_t_time']) && $data['hsl_t_time']) {
// $arr['跟单时间'] = $log->t_time ? date('Y-m-d H:i:s', $log->t_time) : null;
// }
// return $arr;
// });
// // 创建一个新的 Spreadsheet 对象
// $spreadsheet = new Spreadsheet();
// // 设置当前活动的工作表
// $sheet = $spreadsheet->getActiveSheet();
// // 设置工作表的标题
// $sheet->setCellValue('A1', '订单号');
// $sheet->setCellValue('B1', '录入人');
// $sheet->setCellValue('C1', '接单人');
// $sheet->setCellValue('D1', '等级');
// $sheet->setCellValue('E1', '邮箱');
// $sheet->setCellValue('F1', '电话');
// $sheet->setCellValue('G1', 'WhatsApp');
// $sheet->setCellValue('H1', '联系人');
// $sheet->setCellValue('I1', '国家(中文)');
// $sheet->setCellValue('J1', '国家(英文)');
// $sheet->setCellValue('K1', '发单时间');
// $sheet->setCellValue('L1', '跟单时间');
// $data = $results->toArray();
// // 将数据填充到工作表中
// $row = 2; // 从第2行开始填充
// foreach ($data as $item) {
// $sheet->setCellValue('A' . $row, $item['订单号']);
// $sheet->setCellValue('B' . $row, $item['录入人']);
// $sheet->setCellValue('C' . $row, $item['接单人']);
// $sheet->setCellValue('D' . $row, $item['等级']);
// $sheet->setCellValue('E' . $row, $item['邮箱']);
// $sheet->setCellValue('F' . $row, $item['电话']);
// $sheet->setCellValue('G' . $row, $item['WhatsApp']);
// $sheet->setCellValue('H' . $row, $item['联系人']);
// $sheet->setCellValue('I' . $row, $item['国家(中文)']);
// $sheet->setCellValue('J' . $row, $item['国家(英文)']);
// $sheet->setCellValue('K' . $row, $item['发单时间']);
// $sheet->setCellValue('L' . $row, $item['跟单时间']);
// $row++;
// }
// // 创建 Excel 文件的写入器
// $writer = new Xlsx($spreadsheet);
// // 名称
// $fileName = $request->user->name.'_'.date('Ymd').'_'.uniqid().'.xlsx';
// // 路径
// $filePath = '/export/'.$fileName;
// // 将文件保存到本地
// $writer->save(public_path().$filePath);
// return $this->successResponse(['url'=>$filePath, 'name'=>$fileName]);
// }
// return view('inquiry-log/export');
// }
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\inquiry\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\inquiry\model\Country;
class CountryLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Country::class;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\inquiry\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\InquiryLog;
class InquiryLogLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = InquiryLog::class;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace app\inquiry\service;
use app\inquiry\validate\CountryValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\inquiry\model\Country;
class CountryService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = Country::class;
/**
* 验证器
*/
protected $validator = CountryValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace app\inquiry\service;
use app\common\validate\InquiryLogValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\InquiryLog;
use support\exception\BusinessException;
use support\Request;
use Webman\RedisQueue\Redis as RedisQueueRedis;
class InquiryLogService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = InquiryLog::class;
/**
* 验证器
*/
protected $validator = InquiryLogValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 发布
*/
public function serviceRelease(Request $request, array $data = [])
{
$data = $this->preProcess('Release', $data, $request);
$config = $this->buildQueryConfig($request, 'release');
$updated = $this->logicUpdateBy($config, $data);
return $this->postProcess('Release', $data);
}
/**
* 分配
*/
public function serviceAllocation(Request $request, array $data = [])
{
$data = $this->preProcess('Allocation', $data, $request);
$config = $this->buildQueryConfig($request, 'allocation');
$updated = $this->logicUpdateBy($config, $data);
return $this->postProcess('Allocation', $data);
}
/**
* 构建查询条件
*/
protected function buildQueryConfig(Request $request, string $scene = 'index'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'index':
// ====== ====== 排序 ====== ======
$config['order']['id'] = 'desc';
// ====== ====== 筛选 ====== ======
// 渠道
if (isset($p['channel']) && $p['channel']) {
$config['where']['channel'] = $p['channel'];
}
// 国家
if (isset($p['country']) && $p['country']) {
$config['where']['country'] = $p['country'];
}
// 时间
if (isset($p['time']) && $p['time']) {
$time = explode(' - ', $p['time']);
$config['where']['created_at'] = [
'between', [strtotime($time[0]), strtotime($time[1])]
];
}
// 状态
if (isset($p['status']) && in_array($p['status'], [0,1,2])) {
$config['where']['status'] = $p['status'];
}
// 关键字
if (isset($p['keyword']) && $p['keyword']) {
$k = '%' . $p['keyword'] . '%';
foreach (['order_no','email','tel','whatsapp','contacts'] as $col) {
$config['orWhere'][] = [$col, 'like', $k];
}
}
// ====== ====== 关联 ====== ======
$config['with']['countryInfo'][] = 'cn_addr';
$config['with']['countryInfo'][] = 'steering';
$config['with']['enter'][] = 'name';
$config['with']['recipient'][] = 'name';
$config['with']['channelInfo'][] = 'name';
break;
case 'show':
case 'update':
case 'destroy':
case 'release':
case 'allocation':
$config['where']['id'] = $p['id'];
case 'store':
break;
}
return $config;
}
/**
* 添加前置处理
*/
public function beforeServiceStore($data, Request $request)
{
$data['order_no'] = orderNum();
$data['enter_user_id'] = $request->user->uid;
$data['recipient'] = 0;
$data['email'] = trim($data["email"]);
$data['tel'] = trim($data['tel']);
$data['whatsapp'] = trim($data['whatsapp']);
// 将OSS直连地址替换为代理地址
$ossDomain = 'https://vehicle-album.oss-cn-beijing.aliyuncs.com/';
$proxyPath = getenv('MAIN_DOMAIN').'/proxy/ossurl?file=';
$data['content'] = str_replace(
'?Expires=',
'&Expires=',
$data['content']
);
$data['content'] = str_replace(
$ossDomain,
$proxyPath,
$data['content']
);
return $data;
}
/**
* 发布前置处理
*/
public function beforeServiceRelease($data, Request $request)
{
$info = $this->findByPk($request->post('id'));
if (!$info) {
throw new BusinessException('数据不存在');
}
if ($info->status != 0) {
throw new BusinessException('询单已被发布');
}
$data['r_time'] = time();
$data['status'] = 1;
return $data;
}
/**
* 分配前置处理
*/
public function beforeServiceAllocation($data, Request $request)
{
$info = $this->findByPk($request->post('id'));
if (!$info) {
throw new BusinessException('数据不存在');
}
if ($info->status == 2) {
throw new BusinessException('询单已被分配');
}
if ($info->status == 0) {
$data['r_time'] = time();
}
$data['t_time'] = time();
$data['status'] = 2;
return $data;
}
/**
* 发布后置处理
*/
protected function afterServiceRelease($res)
{
RedisQueueRedis::send('inquiry-to-crm', $res);
return $res;
}
/**
* 分配后置处理
*/
protected function afterServiceAllocation($res)
{
RedisQueueRedis::send('inquiry-to-crm', $res);
return $res;
}
/**
* 获取询单ById
* @param int|string $id
* @param string $orderSn
* @return Model|null
*/
public function getInquiryLogBy(int|string $id)
{
$config = [];
$config['model'] = $this->modelClass;
$config['with']['countryInfo'][] = 'cn_addr';
$config['with']['countryInfo'][] = 'steering';
$config['with']['enter'][] = 'name';
$config['with']['recipient'][] = 'name';
$config['with']['channelInfo'][] = 'name';
$config['orWhere'][] = ['id', '=', $id];
$config['orWhere'][] = ['order_no', '=', $id];
return $this->logicShow($config);
}
/**
* 获取等级名称
* @param $id
* @return string
*/
public function qualityName($id)
{
if (!$id) {
return '🤍 银牌商机';
}
$quality = [
1 => '💛金牌商机',
2 => '🤍银牌商机',
3 => '🧡铜牌商机'
];
return $quality[$id];
}
}
+6 -2
View File
@@ -12,7 +12,9 @@ class AuthCheck implements MiddlewareInterface
public function process(Request $request, callable $handler) : Response
{
$controller = new ReflectionClass($request->controller);
$noNeedLogin = $controller->getDefaultProperties()['noNeedLogin'] ?? [];
$properties = $controller->getDefaultProperties();
$noNeedLogin = $properties['noNeedLogin'] ?? [];
$noNeedAuth = $properties['noNeedAuth'] ?? [];
if (in_array($request->action, $noNeedLogin)) {
// 白名单
@@ -31,6 +33,7 @@ class AuthCheck implements MiddlewareInterface
}
$token = $matches[1];
$request->user = (new Jwt())->verifyToken($token);
return $handler($request);
} catch (\Throwable $e) {
$body = [
'status' => 'fail',
@@ -40,7 +43,8 @@ class AuthCheck implements MiddlewareInterface
];
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
}
return $handler($request);
}
return $handler($request);
}
}
+9 -3
View File
@@ -12,9 +12,9 @@ class BasicData implements MiddlewareInterface
public function process(Request $request, callable $handler): Response
{
$controller = new ReflectionClass($request->controller);
$noNeedHeader = $controller->getDefaultProperties()['noNeedHeader'] ?? [];
$noNeedHeaderData = $controller->getDefaultProperties()['noNeedHeaderData'] ?? [];
if (in_array($request->action, $noNeedHeader) || in_array($request->path(), config('whitelist.basi_cdata'))) {
if (in_array($request->action, $noNeedHeaderData)) {
// 白名单
return $handler($request);
} else {
@@ -35,7 +35,13 @@ class BasicData implements MiddlewareInterface
}
return $handler($request);
} catch (\Throwable $th) {
throw $th;
$body = [
'status' => 'fail',
'code' => 403,
'message' => $th->getMessage(),
'data' => [],
];
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
}
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
use app\service\AuthService;
class PermissionMiddleware implements MiddlewareInterface
{
protected $permission_code;
// 支持传参(如 "user:view"),可以在路由闭包里传入
public function __construct($permission_code = null)
{
$this->permission_code = $permission_code;
}
public function process(Request $request, callable $next): Response
{
// 假设有 auth() helper 方法能获取当前登录用户ID
$user_id = $request->session()->get('user_id');
if (!$user_id) {
return json(['code' => 401, 'msg' => '未登录']);
}
if ($this->permission_code && !AuthService::check($user_id, $this->permission_code)) {
return json(['code' => 403, 'msg' => '没有权限']);
}
return $next($request);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace app\process;
use app\crm\model\Customer;
use support\Log;
use Workerman\Crontab\Crontab;
class Task
{
public function onWorkerStart()
{
// 每半小时执行一次
new Crontab('0 */30 * * * *', function(){
echo date('Y-m-d H:i:s') . "\n";
Log::info('send follow start',[time()]);
$where = [
['follow_next', '<=', time() + 3600],
['follow_next', '>', time()],
['warn', '<', 1]
];
$customer = Customer::where($where)->get();
foreach ($customer as $item) {
$res =[
'id'=>$item->id,
'category'=>'crm',
'scene'=>'crm-person-follow-up',
'receive type'=>'person'
];
\Webman\RedisQueue\Redis::send("feishu-message",$res);
$item->warn = 1;
$item->save();
}
Log::info('send follow start',[time()]);
});
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace app\queue\redis;
use app\common\model\CorpFeishuApp;
use app\common\model\CrmCustomer;
use Webman\RedisQueue\Consumer;
use app\common\model\User;
use app\common\utils\FeishuMessage as FeishuMsg;
use support\exception\BusinessException;
use app\inquiry\service\InquiryLogService;
class FeishuMessage implements Consumer
{
// 要消费的队列名
public $queue = 'feishu-message';
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
public $connection = 'default';
// 消费
/**
* @param string $category 分类:crm | inquiry
* @param string $scene 场景:group-send | group-update | person-send | person-update
* @param string $id 数据ID:询单ID | 客户ID
* @param string $receive_type 接收者类型:group | person
*/
public function consume($data)
{
try {
if ($data['category'] === 'crm') {
// 客户线索
$crmCustomer = new CrmCustomer();
$info = $crmCustomer->where('id', $data['id'])->first();
// 跟单人
$reInfo = User::where('id', $info->user_id)->first();
// 飞书应用
$feishuApp = new CorpFeishuApp();
$feishuAppInfo = $feishuApp->where('corp_id', $reInfo->corp_id)->where('system_id', 1)->first();
// 推送数据
$options = [
'contacts' => $info->contacts,
'follow_last' => $info->follow_last ? date('Y-m-d H:i', $info->follow_last) : '',
'follow_next' => $info->follow_next ? date('Y-m-d H:i', $info->follow_next) : '',
'app_id' => $feishuAppInfo->app_id,
'app_secret' => $feishuAppInfo->app_secret,
'msg_type' => 'interactive',
'receive_id_type' => 'open_id',
'receive_id' => $reInfo->open_id
];
}
if ($data['category'] === 'inquiry') {
// 询单信息
$inquiryLogService = new InquiryLogService();
$info = $inquiryLogService->getInquiryLogBy($data['id']);
// 接受者处理
if ($data['receive_type'] == 'group') {
$appId = 'cli_a72f13d6c022500d';
$appSecret = 'Kxtbq70OQWrEhpltrufRZc76z7kjDqPT';
$receiveId = 'oc_4f574e6f8bc5b8f3e5175e67ebc5725c';
$receiveIdType = 'chat_id';
} else {
// 跟单人
$reInfo = User::where('id', $info->recipient)->first();
// 飞书应用
$feishuApp = new CorpFeishuApp();
$feishuAppInfo = $feishuApp->where('corp_id', $reInfo->corp_id)->where('system_id', 1)->first();
// 推送信息
$appId = $feishuAppInfo->app_id;
$appSecret = $feishuAppInfo->app_secret;
$receiveId = $reInfo->open_id;
$receiveIdType = 'open_id';
}
// 推送数据
$options = [
'order_no' => $info->order_no,
'quality' => $inquiryLogService->qualityName($info->quality),
'channel' => $info->channelInfo ? $info->channelInfo->name : '',
'country' => $info->countryInfo ? $info->countryInfo->cn_addr : '',
'enter_user' => $info->enter ? $info->enter->name : '',
'r_time' => $info->r_time ? date('Y-m-d H:i:s', $info->r_time) : '',
't_time' => $info->t_time ? date('Y-m-d H:i:s', $info->t_time) : '',
'app_id' => $appId,
'app_secret' => $appSecret,
'msg_type' => 'interactive',
'receive_id_type' => $receiveIdType,
'receive_id' => $receiveId
];
}
$feishuMsg = new FeishuMsg();
$feishuMsg->messageSend($data['scene'], $options);
} catch (\Throwable $th) {
throw $th;
}
}
// 消费失败回调
/*
$package = [
'id' => 1357277951, // 消息ID
'time' => 1709170510, // 消息时间
'delay' => 0, // 延迟时间
'attempts' => 2, // 消费次数
'queue' => 'send-mail', // 队列名
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
'max_attempts' => 5, // 最大重试次数
'error' => '错误信息' // 错误信息
]
*/
public function onConsumeFailure(\Throwable $e, $package)
{
echo "consume failure\n";
echo $e->getMessage() . "\n";
// 无需反序列化
var_export($package);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace app\queue\redis;
use app\inquiry\service\InquiryLogService;
use app\common\model\CrmCustomer;
use app\common\model\InquiryLog;
use Webman\RedisQueue\Consumer;
use app\common\model\User;
use app\common\utils\FeishuMessage;
use support\exception\BusinessException;
class InquiryToCrm implements Consumer
{
// 要消费的队列名
public $queue = 'inquiry-to-crm';
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
public $connection = 'default';
// 消费
public function consume($data)
{
try {
// 获取询盘信息
$info = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('id', $data['id'])->where('sync_crm', 0)->first();
if ($info) {
// 获取用户信息
$recipient = 0;
$corpId = 0;
if ($info->recipient) {
$recipient = $info->recipient;
$uinfo = User::where('id', $info->recipient)->first();
if ($uinfo) {
$corpId = $uinfo->corp_id;
}
// 消息推送参数 分配
$scene = 'inquiry-person-allocation';
$receiveType = 'person';
} else {
// 消息推送参数 发布
$scene = 'inquiry-group-send';
$receiveType = 'group';
}
// 同步客户数据
$crmCustomer = new CrmCustomer();
$crmCustomer->sn = $info->order_no;
$crmCustomer->corp_abbr = $corpId;
$crmCustomer->user_id = $recipient;
$crmCustomer->type = 1;
$crmCustomer->channel_id = $info->channel;
$crmCustomer->country_id = $info->country;
$crmCustomer->magnitude = $info->quality - 1;
$crmCustomer->email = $info->email;
$crmCustomer->phone = $info->tel;
$crmCustomer->WhatsApp = $info->whatsapp;
$crmCustomer->contacts = $info->contacts;
$crmCustomer->desc_collect = $info->content;
$crmCustomer->enter_user_id = $info->enter_user_id;
// 修改同步CRM状态
$info->sync_crm = 1;
// 保存
if (!$crmCustomer->save() || !$info->save()) {
throw new BusinessException('同步失败~');
}
// 发送飞书消息
$options = [
'id' => $info->id,
'category' => 'inquiry',
'scene' => $scene,
'receive_type' => $receiveType
];
\Webman\RedisQueue\Redis::send('feishu-message', $options);
}
} catch (\Throwable $th) {
throw $th;
}
}
// 消费失败回调
/*
$package = [
'id' => 1357277951, // 消息ID
'time' => 1709170510, // 消息时间
'delay' => 0, // 延迟时间
'attempts' => 2, // 消费次数
'queue' => 'send-mail', // 队列名
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
'max_attempts' => 5, // 最大重试次数
'error' => '错误信息' // 错误信息
]
*/
public function onConsumeFailure(\Throwable $e, $package)
{
echo "consume failure\n";
echo $e->getMessage() . "\n";
// 无需反序列化
var_export($package);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
// const REDIRECT_URI = encodeURIComponent('https://example.com/api/oauth/callback/feishu');
// const STATE = '随机串_防CSRF';
// // 用于跳转获取 code 的 URL
const GOTO = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=cli_a72f13d6c022500d&redirect_uri=https://mzl.dev.hnxinyouhe.com/common/auth/feishu/auth&response_type=code&state=MZL`;
const GOTO = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=cli_a72f13d6c022500d&redirect_uri=https://web-fdesk.hnxinyouhe.com/auth/feishu-callback&response_type=code&state=MZL`;
// const GOTO = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=cli_a73e1e6406fa501c&redirect_uri=https://mzl.dev.hnxinyouhe.com/common/auth/feishu/auth&scope=contact:user.base:readonly contact:contact.base:readonly contact:user.employee_id:readonly im:message:send_as_bot&state=xinyouhe`;
// 2. 创建二维码实例
const QRLoginObj = QRLogin({
+3 -1
View File
@@ -41,7 +41,9 @@
"shopwwi/webman-filesystem": "1.1",
"shopwwi/filesystem-oss": "^1.0",
"webman/redis-queue": "^2.1",
"symfony/translation": "^7.3"
"symfony/translation": "^7.3",
"symfony/filesystem": "^7.3",
"workerman/crontab": "^1.0"
},
"suggest": {
"ext-event": "For better performance. "
Generated
+117 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "30cc3cbb162022972a21efed9b69d1e1",
"content-hash": "8ef9d9a07b2be477345002a7623ea951",
"packages": [
{
"name": "aliyuncs/oss-sdk-php",
@@ -4284,6 +4284,72 @@
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/filesystem",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb",
"reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-10-25T15:15:23+00:00"
},
{
"name": "symfony/finder",
"version": "v7.3.0",
@@ -6226,6 +6292,56 @@
},
"time": "2025-02-17T03:34:21+00:00"
},
{
"name": "workerman/crontab",
"version": "v1.0.7",
"source": {
"type": "git",
"url": "https://github.com/walkor/crontab.git",
"reference": "74f51ca8204e8eb628e57bc0e640561d570da2cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/walkor/crontab/zipball/74f51ca8204e8eb628e57bc0e640561d570da2cb",
"reference": "74f51ca8204e8eb628e57bc0e640561d570da2cb",
"shasum": ""
},
"require": {
"php": ">=7.0",
"workerman/workerman": ">=4.0.20"
},
"type": "library",
"autoload": {
"psr-4": {
"Workerman\\Crontab\\": "./src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "http://www.workerman.net",
"role": "Developer"
}
],
"description": "A crontab written in PHP based on workerman",
"homepage": "http://www.workerman.net",
"keywords": [
"crontab"
],
"support": {
"email": "walkor@workerman.net",
"forum": "http://wenda.workerman.net/",
"issues": "https://github.com/walkor/workerman/issues",
"source": "https://github.com/walkor/crontab",
"wiki": "http://doc.workerman.net/"
},
"time": "2025-01-15T07:20:50+00:00"
},
{
"name": "workerman/redis",
"version": "v2.0.4",
+100
View File
@@ -34,4 +34,104 @@ class Constants
self::TICKET_STATUS['REJECTED'] => 'Закрыть',
];
//客户分级 USTOMER_GRADE
public const GRADE = [
'INITIAL' => 0, // 初步接触
'COMMUNICATE' => 1, // 需求沟通
'PLAN' => 2, // 方案推进
'DEAL' => 3, // 成交阶段
'SALES' => 4, // 售后阶段
'LOSS' => 5, // 停滞或流失
];
public const GRADE_ZH = [
self::GRADE['INITIAL'] => '初步接触',
self::GRADE['COMMUNICATE'] => '需求沟通',
self::GRADE['PLAN'] => '方案推进',
self::GRADE['DEAL'] => '成交阶段',
self::GRADE['SALES'] => '售后阶段',
self::GRADE['LOSS'] => '停滞或流失',
];
public const GRADE_EN = [
self::GRADE['INITIAL'] => 'Initial contact',
self::GRADE['COMMUNICATE'] => 'Demand Chat',
self::GRADE['PLAN'] => 'promotion of the plan',
self::GRADE['DEAL'] => 'Transaction Stage',
self::GRADE['SALES'] => 'After Sales',
self::GRADE['LOSS'] => 'Stagnation or loss',
];
public const GRADE_RU = [
self::GRADE['INITIAL'] => 'Первоначальные контакты',
self::GRADE['COMMUNICATE'] => 'Потребности в общении',
self::GRADE['PLAN'] => 'Продвижение программы',
self::GRADE['DEAL'] => 'Этап сделки',
self::GRADE['SALES'] => 'Послепродажный этап',
self::GRADE['LOSS'] => 'Застой или потеря',
];
//客户重要性
public const CUSTOMER_MAGNITUDE_INITIAL = 0; // 金牌商机
public const CUSTOMER_MAGNITUDE_COMMUNICATE = 1; // 银牌商机
public const CUSTOMER_MAGNITUDE_PLAN = 2; // 铜牌商机
public const MAGNITUDE = [
self::CUSTOMER_MAGNITUDE_INITIAL => '金牌商机',
self::CUSTOMER_MAGNITUDE_COMMUNICATE => '银牌商机',
self::CUSTOMER_MAGNITUDE_PLAN => '铜牌商机',
];
public const MAGNITUDE_EN = [
self::CUSTOMER_MAGNITUDE_INITIAL => 'Gold Medal',
self::CUSTOMER_MAGNITUDE_COMMUNICATE => 'Silver Medal',
self::CUSTOMER_MAGNITUDE_PLAN => 'Bronze Medal',
];
public const MAGNITUDE_RU = [
self::CUSTOMER_MAGNITUDE_INITIAL => 'Золотой бизнес',
self::CUSTOMER_MAGNITUDE_COMMUNICATE => 'Серебряный бизнес',
self::CUSTOMER_MAGNITUDE_PLAN => 'Бронзовый бизнес.',
];
//跟进类型
public const CUSTOMER_FOLLOW_VISIT = 0; // 线下拜访
public const CUSTOMER_FOLLOW_UP = 1; // 线上跟进
public const FOLLOW = [
self::CUSTOMER_FOLLOW_VISIT => '线下拜访',
self::CUSTOMER_FOLLOW_UP => '线上跟进',
];
public const FOLLOW_EN = [
self::CUSTOMER_FOLLOW_VISIT => 'Visit',
self::CUSTOMER_FOLLOW_UP => 'On line',
];
//跟进类型
public const CUSTOMER_SEX_UNKNOWN = 0; // 未知
public const CUSTOMER_SEX_MAN = 1; // 男
public const CUSTOMER_SEX_WOMAN = 2; // 女
public const SEX = [
self::CUSTOMER_SEX_UNKNOWN => '未知',
self::CUSTOMER_SEX_MAN => '男',
self::CUSTOMER_SEX_WOMAN => '女',
];
public const SEX_EN = [
self::CUSTOMER_SEX_UNKNOWN => 'unknown',
self::CUSTOMER_SEX_MAN => 'man',
self::CUSTOMER_SEX_WOMAN => 'woman',
];
public const SEX_RU = [
self::CUSTOMER_SEX_UNKNOWN => 'неизвестный',
self::CUSTOMER_SEX_MAN => 'мужской',
self::CUSTOMER_SEX_WOMAN => 'женский',
];
public const SIGN = [
['id' => 'xinyouhe', 'name' => '新友合'],
['id' => 'mbaf', 'name' => 'MAGIC BRIDGE AUTO FZE']
];
public const LANGUAGE = ['en', 'zh_CN','ru'];
}
+1 -1
View File
@@ -5,7 +5,7 @@ return [
'count' => getenv('REDIS_QUEUE_PROCESS_COUNT'), // 可以设置多进程同时消费
'constructor' => [
// 消费者类目录
// 'consumer_dir' => app_path() . '/queue/redis'
'consumer_dir' => app_path() . '/queue/redis',
'consumer_dir' => app_path() . getenv('REDIS_QUEUE_CONSUMER_DIR')
]
]
+4 -1
View File
@@ -59,5 +59,8 @@ return [
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
]
]
]
],
'task' => [
'handler' => app\process\Task::class
],
];

Some files were not shown because too many files have changed in this diff Show More