初始化本地仓库
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\crontab;
|
||||
|
||||
use app\dao\crontab\CrontabLogDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
class CrontabLogService extends BaseService
|
||||
{
|
||||
public function __construct(CrontabLogDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://core.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\crontab;
|
||||
|
||||
use app\dao\crontab\CrontabDao;
|
||||
use app\enum\system\OperationResult;
|
||||
use app\enum\system\TaskScheduleCycle;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\scheduler\Client;
|
||||
use core\scheduler\event\EventBootstrap;
|
||||
use madong\helper\Arr;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 定时任务
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class CrontabService extends BaseService
|
||||
{
|
||||
public function __construct(CrontabDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加定时任务
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function save(array $data): mixed
|
||||
{
|
||||
try {
|
||||
$model = $this->transaction(function () use ($data) {
|
||||
$title = $data['title'] ?? '';
|
||||
$type = $data['type'] ?? '';
|
||||
$target = $data['target'] ?? '';
|
||||
$status = $data['enabled'] ?? 1;
|
||||
$singleton = $data['singleton'] ?? 1;
|
||||
$task_cycle = TaskScheduleCycle::from($data['task_cycle'] ?? 1);
|
||||
$month = $data['month'] ?? '';
|
||||
$week = $data['week'] ?? '';
|
||||
$day = $data['day'] ?? '';
|
||||
$hour = $data['hour'] ?? '';
|
||||
$minute = $data['minute'] ?? '';
|
||||
$second = $data['second'] ?? '';
|
||||
$remark = $data['remark'] ?? '';
|
||||
|
||||
//验证传入参数
|
||||
$this->validateTaskData($task_cycle, $minute, $hour, $day, $week, $month, $second);
|
||||
|
||||
//生成表达式规则
|
||||
$rule = $this->generateCronRule($task_cycle, $minute, $hour, $day, $week, $month, $second);
|
||||
|
||||
//更新到数据库
|
||||
$insertData = [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'rule' => $rule,
|
||||
'target' => $target,
|
||||
'enabled' => $status,
|
||||
'singleton' => $singleton,
|
||||
'task_cycle' => $task_cycle,
|
||||
'cycle_rule' => [
|
||||
'month' => $month,
|
||||
'week' => $week,
|
||||
'day' => $day,
|
||||
'hour' => $hour,
|
||||
'minute' => $minute,
|
||||
'second' => $second,
|
||||
],
|
||||
'remark' => $remark,
|
||||
'first_start' => 0, // 新建任务默认为未启动状态
|
||||
];
|
||||
return $this->dao->save($insertData);
|
||||
});
|
||||
|
||||
//添加定时任务重启服务
|
||||
if (!empty($model)) {
|
||||
$pk = $model->getPk();
|
||||
$this->requestData($model->getData($pk));
|
||||
}
|
||||
return $model;
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新定时任务
|
||||
*
|
||||
* @param $id
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function update($id, array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$title = $data['title'] ?? '';
|
||||
$type = $data['type'] ?? '';
|
||||
$target = $data['target'] ?? '';
|
||||
$status = $data['enabled'] ?? 1;
|
||||
$singleton = $data['singleton'] ?? 1;
|
||||
$task_cycle = TaskScheduleCycle::from($data['task_cycle'] ?? 1);
|
||||
$month = $data['month'] ?? '';
|
||||
$week = $data['week'] ?? '';
|
||||
$day = $data['day'] ?? '';
|
||||
$hour = $data['hour'] ?? '';
|
||||
$minute = $data['minute'] ?? '';
|
||||
$second = $data['second'] ?? '';
|
||||
$remark = $data['remark'] ?? '';
|
||||
//验证传入参数
|
||||
$this->validateTaskData($task_cycle, $minute, $hour, $day, $week, $month, $second);
|
||||
|
||||
//生成表达式规则
|
||||
$rule = $this->generateCronRule($task_cycle, $minute, $hour, $day, $week, $month, $second);
|
||||
|
||||
// 更新到数据库
|
||||
$this->dao->update($id, [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'rule' => $rule,
|
||||
'target' => $target,
|
||||
'enabled' => $status,
|
||||
'singleton' => $singleton,
|
||||
'task_cycle' => $task_cycle->value,
|
||||
'cycle_rule' => [
|
||||
'month' => $month,
|
||||
'week' => $week,
|
||||
'day' => $day,
|
||||
'hour' => $hour,
|
||||
'minute' => $minute,
|
||||
'second' => $second,
|
||||
],
|
||||
'remark' => $remark,
|
||||
]);
|
||||
});
|
||||
// 更新之后重启服务
|
||||
$this->requestData($id);
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务删除
|
||||
*
|
||||
* @param array|int|string $id
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function destroy(array|int|string $id): bool
|
||||
{
|
||||
try {
|
||||
//1.0 先关闭再删除,避免删了后直接连不上服务的情况出现
|
||||
$this->dao->update([['id', 'in', $id]], ['enabled' => 0]);
|
||||
//2.0 重启任务
|
||||
$this->requestData($id);
|
||||
return $this->transaction(function () use ($id) {
|
||||
|
||||
//3.0 删除定时任务跟日志数据
|
||||
$this->dao->destroy($id);
|
||||
$systemCrontabLogService = Container::make(CrontabLogService::class);
|
||||
$systemCrontabLogService->delete(['crontab_id' => $id]);
|
||||
return true;
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复任务
|
||||
*
|
||||
* @param string|int|array $data
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function resumeCrontab(string|int|array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
// 恢复任务时,只更改启用状态
|
||||
$this->query()->whereIn('id', $data)->update(['enabled' => 1]);
|
||||
$result = $this->requestData($data);
|
||||
if (!$result) {
|
||||
throw new AdminException('恢复失败');
|
||||
}
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*暂停任务
|
||||
*
|
||||
* @param string|int|array $data
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function pauseCrontab(string|int|array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$this->query()->whereIn('id', Arr::normalize($data))->update(['enabled' => 0]);
|
||||
$result = $this->requestData($data);
|
||||
if (!$result) {
|
||||
throw new AdminException('重启失败');
|
||||
}
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有任务
|
||||
*
|
||||
* @param string $field
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getTaskAll(string $field = '*'): array
|
||||
{
|
||||
return $this->dao->selectList(['enabled' => 1], $field, 0, 0, '', [], true)->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入ID 获取指定的一条任务
|
||||
*
|
||||
* @param $id
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getTask($id): mixed
|
||||
{
|
||||
return $this->dao->get($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行单个任务
|
||||
*
|
||||
* @param $id
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function runOneTask($id): array
|
||||
{
|
||||
/** @var $task_handle EventBootstrap[] */
|
||||
$task_handle = config('core.scheduler.app.task_handle', []);
|
||||
$crontab = $this->dao->get($id);
|
||||
$start_time = microtime(true);
|
||||
try {
|
||||
if (empty($crontab)) {
|
||||
throw new \Exception('执行任务失败, 任务不存在');
|
||||
}
|
||||
|
||||
if (!isset($task_handle[$crontab->getAttribute('type')])) {
|
||||
throw new \Exception('执行任务失败, 任务类型错误: ');
|
||||
}
|
||||
|
||||
$result_data = $task_handle[$crontab['type']]::parse($crontab);
|
||||
// 记录执行信息
|
||||
$crontab->last_running_time = $start_time;
|
||||
|
||||
$crontab->increment('running_times');
|
||||
if ($crontab->singleton == 0) {
|
||||
// 单次任务 直接停用
|
||||
$crontab->enabled = 0;
|
||||
}
|
||||
if (!$crontab->save()) {
|
||||
throw new \Exception('记录保存失败');
|
||||
}
|
||||
|
||||
$end_time = microtime(true);
|
||||
$log = $result_data['log'] ?? '';
|
||||
if (strlen($log) > 300) {
|
||||
$result_data['log'] = mb_substr($result_data['log'], 0, 300) . '...';
|
||||
}
|
||||
// 写入执行日志
|
||||
$installData = [
|
||||
'crontab_id' => $crontab['id'] ?? '',
|
||||
'target' => $crontab['target'] ?? '',
|
||||
'log' => $result_data['log'] ?? '--',
|
||||
'return_code' => $result_data['code'] ?? OperationResult::FAILURE->value,
|
||||
'running_time' => round($end_time - $start_time, 6),
|
||||
'created_at' => $start_time,
|
||||
];
|
||||
$crontabLogService = Container::make(CrontabLogService::class);
|
||||
$crontabLogModel = $crontabLogService->dao->save($installData);
|
||||
if (empty($crontabLogModel)) {
|
||||
throw new \Exception('日志记录保存失败');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
|
||||
return ['code' => 1, 'log' => $e->getMessage() . '---任务id: ' . $id];
|
||||
}
|
||||
return [
|
||||
'code' => $installData['return_code'],
|
||||
'log' => $installData['log'],
|
||||
'crontab_id' => $installData['crontab_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启任务
|
||||
*
|
||||
* @param int|string|array $id_str int|string 需要重启的任务id,多个id用,拼接,例:1,2,3,4,5
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function requestData(int|string|array $id_str): bool
|
||||
{
|
||||
// 如果是数组,使用 implode 连接为字符串;如果是单个值,直接使用
|
||||
$ids = is_array($id_str) ? implode(',', $id_str) : $id_str;
|
||||
// 重启任务
|
||||
$param = ['method' => 'crontabReload', 'args' => ['id' => $ids]];
|
||||
return Client::request($param);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*
|
||||
* @param \app\enum\system\TaskScheduleCycle $task_cycle
|
||||
* @param $minute
|
||||
* @param $hour
|
||||
* @param $day
|
||||
* @param $week
|
||||
* @param $month
|
||||
* @param $second
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function validateTaskData(TaskScheduleCycle $task_cycle, $minute, $hour, $day, $week, $month, $second): void
|
||||
{
|
||||
switch ($task_cycle) {
|
||||
case TaskScheduleCycle::DAILY:
|
||||
$this->validateInteger($hour, "请输入执行小时", 23);
|
||||
$this->validateInteger($minute, "请输入执行分钟", 59);
|
||||
break;
|
||||
case TaskScheduleCycle::HOURLY:
|
||||
$this->validateInteger($minute, "请输入执行分钟", 59);
|
||||
break;
|
||||
case TaskScheduleCycle::WEEKLY:
|
||||
$this->validateInteger($week, "请输入星期几执行", 6);
|
||||
$this->validateInteger($hour, "请输入执行小时", 23);
|
||||
$this->validateInteger($minute, "请输入执行分钟", 59);
|
||||
break;
|
||||
case TaskScheduleCycle::MONTHLY:
|
||||
$this->validateInteger($day, "请输入执行天数", 31);
|
||||
$this->validateInteger($hour, "请输入执行小时", 23);
|
||||
$this->validateInteger($minute, "请输入执行分钟", 59);
|
||||
break;
|
||||
case TaskScheduleCycle::YEARLY:
|
||||
$this->validateInteger($month, "请输入执行月份", 12);
|
||||
$this->validateInteger($day, "请输入执行天数", 31);
|
||||
$this->validateInteger($hour, "请输入执行小时", 23);
|
||||
$this->validateInteger($minute, "请输入执行分钟", 59);
|
||||
break;
|
||||
case TaskScheduleCycle::N_HOURS:
|
||||
$this->validateInteger($hour, "请输入N小时", 23);
|
||||
break;
|
||||
case TaskScheduleCycle::N_MINUTES:
|
||||
$this->validateInteger($minute, "请输入N分钟", 59);
|
||||
break;
|
||||
case TaskScheduleCycle::N_SECONDS:
|
||||
$this->validateInteger($second, "请输入N秒数", 59);
|
||||
if (60 % (int)$second !== 0) {
|
||||
throw new \Exception('秒级任务必须是60的因数');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new \Exception("任务周期不正确");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义验证规则器
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function validateInteger($value, $message, $max): void
|
||||
{
|
||||
if (!is_numeric($value) || (int)$value < 0 || (int)$value > $max) {
|
||||
throw new \Exception($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务表达式
|
||||
*
|
||||
* @param \app\enum\system\TaskScheduleCycle $task_cycle
|
||||
* @param $minute
|
||||
* @param $hour
|
||||
* @param $day
|
||||
* @param $week
|
||||
* @param $month
|
||||
* @param $second
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateCronRule(TaskScheduleCycle $task_cycle, $minute, $hour, $day, $week, $month, $second): string
|
||||
{
|
||||
return match ($task_cycle) {
|
||||
TaskScheduleCycle::DAILY => "0 {$minute} {$hour} * * *",
|
||||
TaskScheduleCycle::HOURLY => "0 {$minute} * * * *",
|
||||
TaskScheduleCycle::WEEKLY => "0 {$minute} {$hour} * * {$week}",
|
||||
TaskScheduleCycle::MONTHLY => "0 {$minute} {$hour} {$day} * *",
|
||||
TaskScheduleCycle::YEARLY => "0 {$minute} {$hour} {$day} {$month} *",
|
||||
TaskScheduleCycle::N_HOURS => "0 {$minute} */{$hour} * * *",
|
||||
TaskScheduleCycle::N_MINUTES => "0 */{$minute} * * * *", // N分钟
|
||||
TaskScheduleCycle::N_SECONDS => "*/{$second} * * * * *", // N秒
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\dict;
|
||||
|
||||
use app\dao\dict\DictItemDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 数据字段服务
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class DictItemService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(DictItemDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\dict;
|
||||
|
||||
use app\dao\dict\DictDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 数据字段服务
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
* @method update($where, $data)
|
||||
* @method findItemsByCode($dictType)
|
||||
*/
|
||||
class DictService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(DictDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\gateway;
|
||||
|
||||
use app\dao\gateway\RateLimiterDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
class RetaLimiterService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(RateLimiterDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存key
|
||||
*/
|
||||
const CACHE_KEY = 'rate_limiter';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\gateway;
|
||||
|
||||
use app\dao\gateway\RateRestrictionsDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
class RetaRestrictionsService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(RateRestrictionsDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存key
|
||||
*/
|
||||
const CACHE_KEY = 'rate_restrictions';
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\generator;
|
||||
|
||||
|
||||
use app\dao\generator\GeneratorColumnDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Db;
|
||||
|
||||
class GeneratorColumnService extends BaseService
|
||||
{
|
||||
public function __construct(GeneratorColumnDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\generator;
|
||||
|
||||
use app\dao\generator\GeneratorTableDao;
|
||||
use core\generator\GeneratorEngine;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
use support\Db;
|
||||
use ZipArchive;
|
||||
|
||||
class GeneratorService extends BaseService
|
||||
{
|
||||
public function __construct(GeneratorTableDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库表列表
|
||||
*
|
||||
* @param array $query 查询条件
|
||||
* @param int $page 当前页码
|
||||
* @param int $limit 每页数量
|
||||
*
|
||||
* @return array 分页数据
|
||||
*/
|
||||
public function tableList(array $query = [], int $page = 1, int $limit = 10): array
|
||||
{
|
||||
try {
|
||||
$db = !empty($query['source']) ? Db::connect($query['source']) : Db::connection();
|
||||
$sql = 'show table status';
|
||||
$bindings = [];
|
||||
if (!empty($query['name'])) {
|
||||
$sql .= ' where name = :name';
|
||||
$bindings['name'] = $query['name'];
|
||||
}
|
||||
$list = $db->select($sql, $bindings);
|
||||
$list = is_array($list) ? $list : [];
|
||||
$data = array_map(function ($item) {
|
||||
$item = (array)$item;
|
||||
return [
|
||||
'name' => $item['Name'] ?? '',
|
||||
'engine' => $item['Engine'] ?? '',
|
||||
'rows' => $item['Rows'] ?? 0,
|
||||
'data_free' => $item['Data_free'] ?? 0,
|
||||
'data_length' => $item['Data_length'] ?? 0,
|
||||
'index_length' => $item['Index_length'] ?? 0,
|
||||
'collation' => $item['Collation'] ?? '',
|
||||
'created_date' => $item['Create_time'] ?? '',
|
||||
'updated_date' => $item['Update_time'] ?? '',
|
||||
'comment' => $item['Comment'] ?? '',
|
||||
];
|
||||
}, $list);
|
||||
|
||||
// 计算分页信息
|
||||
$total = count($data);
|
||||
$last_page = ceil($total / $limit);
|
||||
$startIndex = ($page - 1) * $limit;
|
||||
$pageData = array_slice($data, $startIndex, $limit);
|
||||
|
||||
// 返回标准化分页数据
|
||||
return [
|
||||
'items' => $pageData,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'last_page' => $last_page,
|
||||
'from' => $startIndex + 1,
|
||||
'to' => min($startIndex + $limit, $total),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'last_page' => 0,
|
||||
'from' => 0,
|
||||
'to' => 0,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加代码生成
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function store(array $data): void
|
||||
{
|
||||
$this->transaction(function () use ($data) {
|
||||
try {
|
||||
$tablePrefix = config('database.connections.mysql.prefix');
|
||||
$sql = 'SHOW TABLE STATUS';
|
||||
$bindings = [];
|
||||
|
||||
if (!empty($data['name'])) {
|
||||
$sql .= " WHERE Name = ?";
|
||||
$bindings[] = $data['name'];
|
||||
}
|
||||
|
||||
$tables = Db::select($sql, $bindings);
|
||||
$tableInfo = $tables[0] ?? [];
|
||||
if (empty($tableInfo)) {
|
||||
throw new AdminException('DATA_NOT_EXIST');
|
||||
}
|
||||
|
||||
$tableName = preg_replace("/^{$tablePrefix}/", '', $tableInfo->Name, 1);
|
||||
|
||||
$columns = Db::select("SHOW FULL COLUMNS FROM `{$tableInfo->Name}`");
|
||||
$fields = [];
|
||||
foreach ($columns as $column) {
|
||||
$fields[] = [
|
||||
'name' => $column->Field,
|
||||
'type' => $column->Type,
|
||||
'comment' => $column->Comment ?? '',
|
||||
'notnull' => $column->Null === 'NO',
|
||||
'primary' => $column->Key === 'PRI',
|
||||
'dict_type' => '',
|
||||
'plugin' => '',
|
||||
'model' => '',
|
||||
'label_key' => '',
|
||||
'value_key' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/** @var $generateTableService GeneratorTableService */
|
||||
$generateTableService = Container::make(GeneratorTableService::class);
|
||||
$generateTable = $generateTableService->get(['table_name' => $tableName]);
|
||||
if (!empty($generateTable)) {
|
||||
//如果已经有了,就不重复创建
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用模型关联创建数据
|
||||
$tableData = [
|
||||
'table_name' => $tableName,
|
||||
'table_content' => $tableInfo->Comment,
|
||||
'class_name' => $tableName,
|
||||
'module_name' => $tableName,
|
||||
];
|
||||
|
||||
// 创建表记录
|
||||
$tableRecord = $generateTableService->save($tableData);
|
||||
|
||||
// 准备列数据
|
||||
$columnsData = [];
|
||||
$default_column = ['created_at', 'updated_at'];
|
||||
foreach ($fields as $v) {
|
||||
$required = 0;
|
||||
if ($v['notnull'] && !$v['primary'] && !in_array($v['name'], $default_column)) {
|
||||
$required = 1;
|
||||
}
|
||||
|
||||
$columnsData[] = [
|
||||
'column_name' => $v['name'],
|
||||
'column_comment' => $v['comment'],
|
||||
'column_type' => $this->getFieldType($v['type']),
|
||||
'is_required' => $required,
|
||||
'is_pk' => $v['primary'] ? 1 : 0,
|
||||
'is_insert' => !in_array($v['name'], $default_column) ? 1 : 0,
|
||||
'is_update' => !in_array($v['name'], $default_column) ? 1 : 0,
|
||||
'is_lists' => !in_array($v['name'], $default_column) ? 1 : 0,
|
||||
'is_delete' => 0,
|
||||
'query_type' => '=',
|
||||
'view_type' => 'input',
|
||||
'dict_type' => $v['dict_type'] ?? '',
|
||||
'plugin' => $v['plugin'] ?? '',
|
||||
'model' => $v['model'] ?? '',
|
||||
'label_key' => $v['label_key'] ?? '',
|
||||
'value_key' => $v['value_key'] ?? '',
|
||||
];
|
||||
}
|
||||
$tableRecord->columns()->createMany($columnsData);
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新代码生成器表
|
||||
*
|
||||
* @param string $id
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update(string $id, array $data): void
|
||||
{
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
try {
|
||||
/** @var GeneratorTableService $generateTableService */
|
||||
$generateTableService = Container::make(GeneratorTableService::class);
|
||||
$table = $generateTableService->get($id);
|
||||
if (empty($table)) {
|
||||
throw new AdminException('DATA_NOT_EXIST');
|
||||
}
|
||||
|
||||
// 处理软删除配置
|
||||
if (isset($data['config']['is_delete']) && $data['config']['is_delete'] == 1) {
|
||||
$deleteColumnName = $data['config']['delete_column_name'] ?? '';
|
||||
|
||||
// 如果配置了软删除字段名,需要更新对应的columns字段
|
||||
if (!empty($deleteColumnName)) {
|
||||
// 重置所有字段的is_delete为0
|
||||
$table->columns()->update(['is_delete' => 0]);
|
||||
|
||||
// 设置指定字段的is_delete为1
|
||||
$table->columns()->where('column_name', $deleteColumnName)->update(['is_delete' => 1]);
|
||||
}
|
||||
} else {
|
||||
// 禁用软删除,重置所有字段的is_delete为0
|
||||
$table->columns()->update(['is_delete' => 0]);
|
||||
}
|
||||
|
||||
// 处理排序配置
|
||||
if (isset($data['config']['order_type']) && $data['config']['order_type'] > 0) {
|
||||
$orderColumnName = $data['config']['order_column_name'] ?? '';
|
||||
|
||||
// 如果配置了排序字段名,需要更新对应的columns字段
|
||||
if (!empty($orderColumnName)) {
|
||||
// 重置所有字段的is_order为0
|
||||
$table->columns()->update(['is_order' => 0]);
|
||||
|
||||
// 设置指定字段的is_order为1
|
||||
$table->columns()->where('column_name', $orderColumnName)->update(['is_order' => 1]);
|
||||
}
|
||||
} else {
|
||||
// 禁用排序,重置所有字段的is_order为0
|
||||
$table->columns()->update(['is_order' => 0]);
|
||||
}
|
||||
|
||||
// 更新表基本信息
|
||||
$table->update([
|
||||
'table_name' => $data['basic']['table_name'] ?? $table->table_name,
|
||||
'table_content' => $data['basic']['table_content'] ?? $table->table_content,
|
||||
'class_name' => $data['basic']['class_name'] ?? $table->class_name,
|
||||
'module_name' => $data['basic']['module_name'] ?? $table->module_name,
|
||||
'edit_type' => $data['config']['edit_type'] ?? $table->edit_type,
|
||||
'order_type' => $data['config']['order_type'] ?? $table->order_type,
|
||||
'relations' => $data['relations'] ?? $table->relations,
|
||||
'parent_menu' => $data['config']['parent_menu'] ?? '',
|
||||
]);
|
||||
|
||||
// 更新关联的列(除了is_delete和is_order字段)
|
||||
$columns = $data['columns'] ?? [];
|
||||
if (!empty($columns)) {
|
||||
foreach ($columns as $columnId => $columnData) {
|
||||
// 排除is_delete和is_order字段,因为它们已经单独处理
|
||||
unset($columnData['is_delete'], $columnData['is_order'], $columnData['updated_date'], $columnData['created_date']);
|
||||
$table->columns()->where('id', $columnId)->update($columnData);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function destroy(array $data): void
|
||||
{
|
||||
$this->transaction(function () use ($data) {
|
||||
try {
|
||||
/** @var GeneratorTableService $generateTableService */
|
||||
$generateTableService = Container::make(GeneratorTableService::class);
|
||||
$tables = $generateTableService->selectList(['id' => $data]);
|
||||
foreach ($tables as $table) {
|
||||
$table->columns()->delete(); // 删除所有关联的列
|
||||
$table->delete(); // 删除表数据
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览
|
||||
*
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function preview(array $params): array
|
||||
{
|
||||
try {
|
||||
$id = $params['id'];
|
||||
$tableModel = $this->dao->getDetail($id);
|
||||
if (!$tableModel) {
|
||||
throw new AdminException('异常资源不存在');
|
||||
}
|
||||
|
||||
/** @var GeneratorEngine $generateService */
|
||||
$generateService = Container::make(GeneratorEngine::class, ['config'=>$tableModel->toArray()]);
|
||||
return $generateService->preview();
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载
|
||||
*
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function download(string|int $id): array
|
||||
{
|
||||
$tableModel = $this->dao->getDetail($id);
|
||||
if (!$tableModel) {
|
||||
throw new AdminException('异常资源不存在');
|
||||
}
|
||||
/** @var GeneratorEngine $generateService */
|
||||
$generateService = Container::make(GeneratorEngine::class, ['config'=>$tableModel->toArray()]);
|
||||
return $generateService->download();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署
|
||||
*
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function deploy(array $params): array
|
||||
{
|
||||
try {
|
||||
$id = $params['id'];
|
||||
$tableModel = $this->dao->getDetail($id);
|
||||
if (!$tableModel) {
|
||||
throw new AdminException('异常资源不存在');
|
||||
}
|
||||
$moduleNameRestrictions = config('core.generator.app.module_name_restrictions.reserved_names',[]);
|
||||
if(in_array($tableModel->module_name,$moduleNameRestrictions)){
|
||||
throw new AdminException('模块名称不能使用,系统保留名称:'.$tableModel->module_name);
|
||||
}
|
||||
/** @var GeneratorEngine $generateService */
|
||||
$generateService = Container::make(GeneratorEngine::class, ['config'=>$tableModel->toArray()]);
|
||||
return $generateService->deploy();
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getFieldType(string $type): string
|
||||
{
|
||||
if (str_starts_with($type, 'set') || str_starts_with($type, 'dict')) {
|
||||
$result = 'string';
|
||||
} elseif (preg_match('/(double|float|decimal|real|numeric)/is', $type)) {
|
||||
$result = 'float';
|
||||
} elseif (preg_match('/(int|serial|bit)/is', $type)) {
|
||||
$result = 'int';
|
||||
} elseif (preg_match('/bool/is', $type)) {
|
||||
$result = 'bool';
|
||||
} elseif (str_starts_with($type, 'timestamp')) {
|
||||
$result = 'timestamp';
|
||||
} elseif (str_starts_with($type, 'datetime')) {
|
||||
$result = 'datetime';
|
||||
} elseif (str_starts_with($type, 'date')) {
|
||||
$result = 'date';
|
||||
} else {
|
||||
$result = 'string';
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\generator;
|
||||
|
||||
use app\dao\generator\GeneratorTableDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Db;
|
||||
|
||||
class GeneratorTableService extends BaseService
|
||||
{
|
||||
public function __construct(GeneratorTableDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\logs;
|
||||
|
||||
use app\dao\logs\LoginLogDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* @method save(array $data)
|
||||
*/
|
||||
class LoginLogService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(LoginLogDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\logs;
|
||||
|
||||
use app\dao\logs\OperateLogDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* @method save(array $data)
|
||||
*/
|
||||
class OperateLogService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(OperateLogDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\web\MenuDao;
|
||||
use app\dao\member\MemberDao;
|
||||
use app\service\admin\web\MenuService as SiteMenuService;
|
||||
use core\base\BaseService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use support\Container;
|
||||
|
||||
class MemberAuthService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(MemberDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单
|
||||
*
|
||||
* @param string|int $memberId 会员ID
|
||||
* @param bool $includeEmptyCode 是否包含权限码为空的菜单(不需要标签权限的公共菜单)
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getMenusByMemberTags(string|int $memberId, bool $includeEmptyCode = false): ?Collection
|
||||
{
|
||||
if (empty($memberId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$member = $this->dao->find($memberId);
|
||||
if (!$member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$menuIds = [];
|
||||
$tags = $member->tags()->where('enabled', 1)->get();
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$permissions = $tag->permissions()->get();
|
||||
foreach ($permissions as $permission) {
|
||||
$menuIds[] = $permission->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($menuIds) && !$includeEmptyCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$menuIds = array_unique($menuIds);
|
||||
$menuService = new SiteMenuService(new MenuDao());
|
||||
$menus = $this->getMenusByIds($menuService, $menuIds, $includeEmptyCode);
|
||||
|
||||
return $menus->where('menu_type', '!=', 'button');
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标签ID获取菜单
|
||||
*
|
||||
* @param string|int $tagId 标签ID
|
||||
* @param bool $includeEmptyCode 是否包含权限码为空的菜单(不需要标签权限的公共菜单)
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getMenusByTagId(string|int $tagId, bool $includeEmptyCode = false): ?Collection
|
||||
{
|
||||
if (empty($tagId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$menuIds = [];
|
||||
/** @var MemberTagService $memberTagService */
|
||||
$memberTagService = Container::make(MemberTagService::class);
|
||||
$memberTag = $memberTagService->get($tagId);
|
||||
if (!$memberTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$permissions = $memberTag->permissions()->get();
|
||||
foreach ($permissions as $permission) {
|
||||
$menuIds[] = $permission->id;
|
||||
}
|
||||
|
||||
if (empty($menuIds) && !$includeEmptyCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$menuIds = array_unique($menuIds);
|
||||
$menuService = new SiteMenuService(new MenuDao());
|
||||
return $this->getMenusByIds($menuService, $menuIds, $includeEmptyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有菜单
|
||||
*
|
||||
* @param bool $includeEmptyCode 是否包含权限码为空的菜单(不需要标签权限的公共菜单)
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAllMenus(bool $includeEmptyCode = false): Collection
|
||||
{
|
||||
/** @var $siteMenuService SiteMenuService */
|
||||
$siteMenuService = Container::make(SiteMenuService::class);
|
||||
$menuModel = $siteMenuService->dao->getModel();
|
||||
|
||||
if ($includeEmptyCode) {
|
||||
return $menuModel->where('enabled', 1)
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
}
|
||||
|
||||
return $menuModel->where('enabled', 1)
|
||||
->where(function ($q) {
|
||||
$q->whereNotNull('code')->where('code', '!=', '');
|
||||
})
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据IDS输出菜单
|
||||
*
|
||||
* @param \app\service\admin\web\MenuService $menuService
|
||||
* @param array $ids
|
||||
* @param bool $includeEmptyCode 是否包含权限码为空的菜单
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getMenusByIds(SiteMenuService $menuService, array $ids, bool $includeEmptyCode = true): Collection
|
||||
{
|
||||
$menuModel = $menuService->dao->getModel();
|
||||
|
||||
// 如果需要包含权限码为空的菜单
|
||||
if ($includeEmptyCode) {
|
||||
$query = $menuModel->where('enabled', 1);
|
||||
|
||||
// 如果有指定菜单ID,则包含这些菜单和权限码为空的菜单
|
||||
if (!empty($ids)) {
|
||||
$query->where(function ($q) use ($ids) {
|
||||
$q->whereIn('id', $ids)->orWhereNull('code')->orWhere('code', '');
|
||||
});
|
||||
} else {
|
||||
// 没有指定菜单ID时,只返回权限码为空的菜单
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('code')->orWhere('code', '');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('sort')->get();
|
||||
}
|
||||
|
||||
// 不包含权限码为空的菜单,只返回指定ID的菜单
|
||||
if (empty($ids)) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
$chunkSize = 200;
|
||||
$chunks = array_chunk($ids, $chunkSize);
|
||||
|
||||
// 分块查询 + 批量合并(减少内存占用)
|
||||
$allResults = new Collection();
|
||||
foreach ($chunks as $chunk) {
|
||||
$results = $menuModel
|
||||
->whereIn('id', $chunk)
|
||||
->where('enabled', 1)
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
$allResults = $allResults->merge($results);
|
||||
}
|
||||
return $allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色-权限码codes
|
||||
*
|
||||
* @param $memberId
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getCodesByUserTags($memberId): array
|
||||
{
|
||||
if (empty($memberId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$member = $this->dao->find($memberId);
|
||||
if (!$member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$codes = [];
|
||||
$tags = $member->tags()->where('enabled', 1)->get();
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$permissions = $tag->permissions()->get();
|
||||
foreach ($permissions as $permission) {
|
||||
if (!empty($permission->code)) {
|
||||
$codes[] = $permission->code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($codes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberBillDao;
|
||||
use app\dao\member\MemberDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
|
||||
/**
|
||||
* 会员账单服务类
|
||||
*/
|
||||
class MemberBillService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberBillDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额操作
|
||||
*/
|
||||
public function operate(array $data): array
|
||||
{
|
||||
$member = $this->memberDao->get($data['member_id']);
|
||||
if (!$member) {
|
||||
throw new AdminException('会员不存在');
|
||||
}
|
||||
|
||||
$amount = (float)$data['amount'];
|
||||
$type = (int)$data['type'];
|
||||
$remark = $data['remark'] ?? '';
|
||||
|
||||
// 开始事务
|
||||
$this->dao->getModel()->getConnection()->beginTransaction();
|
||||
try {
|
||||
// 更新会员余额
|
||||
if ($type == 1) {
|
||||
$member->balance += $amount;
|
||||
} else {
|
||||
if ($member->balance < $amount) {
|
||||
throw new AdminException('余额不足');
|
||||
}
|
||||
$member->balance -= $amount;
|
||||
}
|
||||
$member->save();
|
||||
|
||||
// 记录账单
|
||||
$billData = [
|
||||
'member_id' => $data['member_id'],
|
||||
'amount' => $amount,
|
||||
'type' => $type,
|
||||
'balance' => $member->balance,
|
||||
'remark' => $remark,
|
||||
'create_time' => time(),
|
||||
];
|
||||
$bill = $this->dao->save($billData);
|
||||
|
||||
$this->dao->getModel()->getConnection()->commit();
|
||||
return $bill->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage(), ['errorCode' => $e->getCode() ?: -1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量余额操作
|
||||
*/
|
||||
public function batchOperate(array $data): array
|
||||
{
|
||||
$memberIds = $data['member_ids'] ?? [];
|
||||
if (empty($memberIds)) {
|
||||
throw new AdminException('请选择会员');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($memberIds as $memberId) {
|
||||
try {
|
||||
$operateData = array_merge($data, ['member_id' => $memberId]);
|
||||
$result = $this->operate($operateData);
|
||||
$results[] = $result;
|
||||
} catch (xception $e) {
|
||||
$results[] = ['member_id' => $memberId, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取余额统计
|
||||
*/
|
||||
public function getStatistics(array $params): array
|
||||
{
|
||||
$startTime = $params['start_time'] ?? null;
|
||||
$endTime = $params['end_time'] ?? null;
|
||||
|
||||
$where = [];
|
||||
if ($startTime) {
|
||||
$where[] = ['create_time', '>=', strtotime($startTime)];
|
||||
}
|
||||
if ($endTime) {
|
||||
$where[] = ['create_time', '<=', strtotime($endTime) + 86399];
|
||||
}
|
||||
|
||||
$totalIncome = $this->dao->query()->where($where)->where('type', 1)->sum('amount') ?: 0;
|
||||
$totalExpense = $this->dao->query()->where($where)->where('type', 2)->sum('amount') ?: 0;
|
||||
$totalRecords = $this->dao->query()->where($where)->count();
|
||||
|
||||
return [
|
||||
'total_income' => $totalIncome,
|
||||
'total_expense' => $totalExpense,
|
||||
'net_income' => $totalIncome - $totalExpense,
|
||||
'total_records' => $totalRecords,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\dao\member\MemberLevelDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
|
||||
/**
|
||||
* 会员等级服务类
|
||||
*/
|
||||
class MemberLevelService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberLevelDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\dao\member\MemberPointsDao;
|
||||
use app\enum\member\PointType;
|
||||
use app\enum\member\PointSource;
|
||||
use app\adminapi\event\PointsChangedEvent;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 会员积分服务类
|
||||
*/
|
||||
class MemberPointsService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberPointsDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分操作
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function operate(array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$memberDao = Container::make(MemberDao::class);
|
||||
$member = $memberDao->get($data['member_id']);
|
||||
$oldPoints = $member->points;
|
||||
if (!$member) {
|
||||
throw new AdminException('会员不存在');
|
||||
}
|
||||
$points = (int)$data['points'];
|
||||
$type = (int)$data['type'];
|
||||
$remark = $data['remark'] ?? '';
|
||||
|
||||
// 更新会员积分
|
||||
if ($type == PointType::INCREASE->value) {
|
||||
$member->points += $points;
|
||||
}
|
||||
if ($type == PointType::DECREASE->value) {
|
||||
if ($member->points < $points) {
|
||||
throw new AdminException('积分不足');
|
||||
}
|
||||
$member->points -= $points;
|
||||
}
|
||||
if($type == PointType::ADJUST->value){
|
||||
$member->points = $points;
|
||||
}
|
||||
$member->save();
|
||||
// 记录积分变动
|
||||
$pointsData = [
|
||||
'member_id' => $member->id,
|
||||
'points' => $points,
|
||||
'type' => $type,
|
||||
'balance' => $member->points,
|
||||
'remark' => $remark,
|
||||
];
|
||||
$this->dao->save($pointsData);
|
||||
|
||||
$event = new PointsChangedEvent(
|
||||
$member->id,
|
||||
$points,
|
||||
PointSource::ADMIN,
|
||||
PointType::INCREASE,
|
||||
$oldPoints,
|
||||
$member->points,
|
||||
$remark
|
||||
);
|
||||
$event->dispatch();
|
||||
});
|
||||
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
//
|
||||
// /**
|
||||
// * 批量积分操作
|
||||
// */
|
||||
// public function batchOperate(array $data): array
|
||||
// {
|
||||
// $memberIds = $data['member_ids'] ?? [];
|
||||
// if (empty($memberIds)) {
|
||||
// throw new AdminException('请选择会员');
|
||||
// }
|
||||
//
|
||||
// $results = [];
|
||||
// foreach ($memberIds as $memberId) {
|
||||
// try {
|
||||
// $operateData = array_merge($data, ['member_id' => $memberId]);
|
||||
// $result = $this->operate($operateData);
|
||||
// $results[] = $result;
|
||||
// } catch (xception $e) {
|
||||
// $results[] = ['member_id' => $memberId, 'error' => $e->getMessage()];
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return $results;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 获取积分统计
|
||||
// */
|
||||
// public function getStatistics(array $params): array
|
||||
// {
|
||||
// $startTime = $params['start_time'] ?? null;
|
||||
// $endTime = $params['end_time'] ?? null;
|
||||
//
|
||||
// $where = [];
|
||||
// if ($startTime) {
|
||||
// $where[] = ['create_time', '>=', strtotime($startTime)];
|
||||
// }
|
||||
// if ($endTime) {
|
||||
// $where[] = ['create_time', '<=', strtotime($endTime) + 86399];
|
||||
// }
|
||||
//
|
||||
// $totalIncome = $this->dao->query()->where($where)->where('type', 1)->sum('points') ?: 0;
|
||||
// $totalExpense = $this->dao->query()->where($where)->where('type', 2)->sum('points') ?: 0;
|
||||
// $totalRecords = $this->dao->query()->where($where)->count();
|
||||
//
|
||||
// return [
|
||||
// 'total_income' => $totalIncome,
|
||||
// 'total_expense' => $totalExpense,
|
||||
// 'net_income' => $totalIncome - $totalExpense,
|
||||
// 'total_records' => $totalRecords,
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 设置积分规则
|
||||
// */
|
||||
// public function setRules(array $data): array
|
||||
// {
|
||||
// $rules = [
|
||||
// 'sign_points' => $data['sign_points'] ?? 10,
|
||||
// 'login_points' => $data['login_points'] ?? 5,
|
||||
// 'max_sign_days' => $data['max_sign_days'] ?? 30,
|
||||
// ];
|
||||
//
|
||||
// $config = $this->configDao->getByKey('member_points_rules');
|
||||
// if ($config) {
|
||||
// $config->value = json_encode($rules);
|
||||
// $config->save();
|
||||
// } else {
|
||||
// $this->configDao->save([
|
||||
// 'key' => 'member_points_rules',
|
||||
// 'value' => json_encode($rules),
|
||||
// 'name' => '会员积分规则',
|
||||
// 'group' => 'member',
|
||||
// 'type' => 'json',
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// return $rules;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 获取积分规则
|
||||
// */
|
||||
// public function getRules(): array
|
||||
// {
|
||||
// $config = $this->configDao->getByKey('member_points_rules');
|
||||
// if ($config) {
|
||||
// return json_decode($config->value, true) ?: [];
|
||||
// }
|
||||
//
|
||||
// // 默认规则
|
||||
// return [
|
||||
// 'sign_points' => 10,
|
||||
// 'login_points' => 5,
|
||||
// 'max_sign_days' => 30,
|
||||
// ];
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\enum\member\PointSource;
|
||||
use app\enum\member\PointType;
|
||||
use app\adminapi\event\PointsChangedEvent;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 会员服务类
|
||||
* @method getUsersListByTagId(mixed $where, mixed $field, mixed $page, mixed $limit)
|
||||
* @method getUsersExcludingTag(mixed $where, mixed $field, mixed $page, mixed $limit)
|
||||
*/
|
||||
class MemberService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配标签
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function assignTags(int|string $id, array $tags = []): void
|
||||
{
|
||||
try {
|
||||
$model = $this->dao->get($id);
|
||||
if (empty($model)) {
|
||||
throw new AdminException('会员不存在');
|
||||
}
|
||||
$model->tags()->sync($tags);
|
||||
} catch (\Exception $exception) {
|
||||
throw new AdminException($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整会员积分(使用 Laravel ORM)
|
||||
*
|
||||
* @param int|string $id 会员ID
|
||||
* @param int $points 积分变动值(正数增加,负数减少)
|
||||
* @param array $options
|
||||
*
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function adjustPoints(int|string $id, int $points, array $options = []): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $points, $options) {
|
||||
// 使用 Laravel ORM 查询会员
|
||||
$member = $this->dao->get($id);
|
||||
if (!$member) {
|
||||
throw new AdminException('会员不存在');
|
||||
}
|
||||
// 检查积分是否足够(如果是减少积分)
|
||||
if ($points < 0 && $member->points < abs($points)) {
|
||||
throw new AdminException('会员积分不足');
|
||||
}
|
||||
|
||||
// 计算新积分
|
||||
$newPoints = abs($points);
|
||||
// 积分不能小于0
|
||||
if ($newPoints < 0) {
|
||||
throw new AdminException('积分不能小于0');
|
||||
}
|
||||
//添加积分记录
|
||||
$pointsService = Container::make(MemberPointsService::class);
|
||||
$params = [
|
||||
'member_id' => $member->id,
|
||||
'points' => $points,
|
||||
'balance' => $newPoints,
|
||||
'type' => $options['type'] ?? PointType::ADJUST->value,
|
||||
'source' => $options['source'] ?? PointSource::ADMIN->value,
|
||||
'remark' => $options['remark'] ?? '',
|
||||
];
|
||||
$pointsService->save($params);
|
||||
$member->update(['points' => $newPoints]);
|
||||
|
||||
$source = PointSource::ADMIN;
|
||||
if (!empty($params['source'])) {
|
||||
$source = PointSource::tryFrom($params['source']) ?? PointSource::ADMIN;
|
||||
}
|
||||
|
||||
$event = new PointsChangedEvent(
|
||||
$member->id,
|
||||
$points,
|
||||
$source,
|
||||
PointType::INCREASE,
|
||||
$member->points,
|
||||
$newPoints,
|
||||
$options['remark'] ?? ''
|
||||
);
|
||||
$event->dispatch();
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
throw new AdminException('调整积分失败: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberSignDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 会员签到服务类
|
||||
*/
|
||||
class MemberSignService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberSignDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员签到统计
|
||||
*/
|
||||
public function getMemberSignStatistics(int $memberId): array
|
||||
{
|
||||
return $this->dao->getSignStatistics($memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员签到记录
|
||||
*/
|
||||
public function getMemberSignRecords(int $memberId, int $days = 30): array
|
||||
{
|
||||
return $this->dao->getMemberSignRecords($memberId, $days);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动为会员签到
|
||||
*/
|
||||
public function manualSign(int $memberId, int $points = 10): array
|
||||
{
|
||||
return $this->dao->create($memberId, date('Y-m-d'), $points, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到日历数据
|
||||
*/
|
||||
public function getSignCalendar(int $memberId, string $yearMonth): array
|
||||
{
|
||||
$year = substr($yearMonth, 0, 4);
|
||||
$month = substr($yearMonth, 5, 2);
|
||||
|
||||
$startDate = "{$year}-{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$signRecords = $this->dao->where('member_id', $memberId)
|
||||
->whereBetween('sign_date', [$startDate, $endDate])
|
||||
->pluck('sign_date')
|
||||
->toArray();
|
||||
|
||||
$calendar = [];
|
||||
$currentDate = $startDate;
|
||||
|
||||
while ($currentDate <= $endDate) {
|
||||
$calendar[] = [
|
||||
'date' => $currentDate,
|
||||
'signed' => in_array($currentDate, $signRecords),
|
||||
'day' => date('d', strtotime($currentDate))
|
||||
];
|
||||
$currentDate = date('Y-m-d', strtotime($currentDate . ' +1 day'));
|
||||
}
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberTagPermissionDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 会员标签权限服务类
|
||||
*/
|
||||
class MemberTagPermissionService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberTagPermissionDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设置角色权限(含事务封装)
|
||||
*
|
||||
* @param $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function save($data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$this->saveWithoutTransaction($data);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无事务版本:核心业务逻辑(供内部/外部事务复用)
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function saveWithoutTransaction(array $data): void
|
||||
{
|
||||
try {
|
||||
$tagId = $data['tag_id'] ?? '';
|
||||
$newPermissions = $data['menu_id'] ?? [];
|
||||
if (empty($tagId)) {
|
||||
throw new AdminException('参数错误缺少tag_id', ['errorCode' => -1]);
|
||||
}
|
||||
// 1. 获取会员标签模型(确保会员标签存在)
|
||||
/** @var MemberTagService $tagService */
|
||||
$tagService = Container::make(MemberTagService::class);
|
||||
$tagModel = $tagService->get($tagId, ['*'], ['permissions']);
|
||||
if (!$tagModel) {
|
||||
throw new AdminException('会员标签不存在', ['errorCode' => -1]);
|
||||
}
|
||||
$tagModel->permissions()->sync($newPermissions);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberTagRelationDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
|
||||
|
||||
class MemberTagRelationService extends BaseService
|
||||
{
|
||||
public function __construct(MemberTagRelationDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除会员用户-关联标签
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function removeUserTag(array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
foreach ($data as $item) {
|
||||
$this->dao->getModel()->where($item)->delete();
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户-关联标签
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return array 返回保存结果 ['saved' => int, 'duplicates' => int, 'failed' => int]
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function saveUserTags(array $data): array
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($data) {
|
||||
// 检查数据有效性
|
||||
if (empty($data)) {
|
||||
throw new AdminException('数据不能为空');
|
||||
}
|
||||
// 检查必填字段
|
||||
foreach ($data as $index => $item) {
|
||||
if (!isset($item['member_id']) || !isset($item['tag_id'])) {
|
||||
throw new AdminException("数据格式错误: 第 {$index} 条记录缺少 member_id 或 tag_id");
|
||||
}
|
||||
}
|
||||
// 使用专门的批量保存方法
|
||||
$result = $this->dao->batchSave($data);
|
||||
// 如果没有任何数据被保存且没有重复记录,则认为是失败
|
||||
if ($result['saved'] === 0 && $result['duplicates'] === 0 && $result['failed'] > 0) {
|
||||
throw new AdminException('批量保存失败: ' . implode(', ', $result['errors']));
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberDao;
|
||||
use app\dao\member\MemberTagDao;
|
||||
use app\dao\member\MemberTagRelationDao;
|
||||
use app\model\web\Menu;
|
||||
use app\model\member\MemberTag;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 会员标签服务类
|
||||
*/
|
||||
class MemberTagService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MemberTagDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为会员添加标签
|
||||
*/
|
||||
public function addTagToMember(int $memberId, int $tagId): bool
|
||||
{
|
||||
/** @var MemberTagRelationDao $dao */
|
||||
$dao = Container::make(MemberTagRelationDao::class);
|
||||
return $dao->addTagRelation($memberId, $tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为会员移除标签
|
||||
*/
|
||||
public function removeTagFromMember(int $memberId, int $tagId): bool
|
||||
{
|
||||
/** @var MemberTagRelationDao $dao */
|
||||
$dao = Container::make(MemberTagRelationDao::class);
|
||||
return $dao->removeTagRelation($memberId, $tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签下的会员列表
|
||||
*/
|
||||
public function getTagMembers(int $tagId, array $params): array
|
||||
{
|
||||
$tag = $this->dao->get($tagId);
|
||||
if (!$tag) {
|
||||
throw new AdminException('标签不存在');
|
||||
}
|
||||
|
||||
$page = (int)($params['page'] ?? 1);
|
||||
$limit = (int)($params['limit'] ?? 10);
|
||||
|
||||
/** @var MemberTagRelationDao $relationDao */
|
||||
$relationDao = Container::make(MemberTagRelationDao::class);
|
||||
/** @var MemberDao $memberDao */
|
||||
$memberDao = Container::make(MemberDao::class);
|
||||
|
||||
// 获取标签关联的会员ID
|
||||
$relations = $relationDao->selectList(
|
||||
[['tag_id', '=', $tagId]],
|
||||
'member_id',
|
||||
0, 10000
|
||||
);
|
||||
$memberIds = $relations->pluck('member_id')->toArray();
|
||||
|
||||
if (empty($memberIds)) {
|
||||
return ['total' => 0, 'list' => []];
|
||||
}
|
||||
|
||||
$where = [['id', 'in', $memberIds]];
|
||||
$field = '*';
|
||||
$order = 'create_time desc';
|
||||
|
||||
[$total, $list] = $memberDao->getList($where, $field, $page, $limit, $order);
|
||||
|
||||
return compact('total', 'list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量分配标签
|
||||
*/
|
||||
public function batchAssignTags(array $data): array
|
||||
{
|
||||
$memberIds = $data['member_ids'] ?? [];
|
||||
$tagIds = $data['tag_ids'] ?? [];
|
||||
$action = $data['action'] ?? 'assign';
|
||||
|
||||
if (empty($memberIds)) {
|
||||
throw new AdminException('请选择会员');
|
||||
}
|
||||
|
||||
if (empty($tagIds)) {
|
||||
throw new AdminException('请选择标签');
|
||||
}
|
||||
|
||||
/** @var MemberTagRelationDao $relationDao */
|
||||
$relationDao = Container::make(MemberTagRelationDao::class);
|
||||
|
||||
$relationDao->getModel()->getConnection()->beginTransaction();
|
||||
try {
|
||||
foreach ($memberIds as $memberId) {
|
||||
if ($action == 'assign') {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$this->addTagToMember($memberId, $tagId);
|
||||
}
|
||||
} else if ($action == 'remove') {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$this->removeTagFromMember($memberId, $tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relationDao->getModel()->getConnection()->commit();
|
||||
return ['success' => true, 'count' => count($memberIds)];
|
||||
} catch (\Exception $e) {
|
||||
$relationDao->getModel()->getConnection()->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的标签
|
||||
*/
|
||||
public function getEnabledTags(): array
|
||||
{
|
||||
$where = [['status', '=', 1]];
|
||||
$list = $this->dao->selectList($where, '*', 0, 1000, 'sort asc');
|
||||
return $list->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新标签排序
|
||||
*/
|
||||
public function updateSort(int $id, int $sort): array
|
||||
{
|
||||
$tag = $this->dao->get($id);
|
||||
if (!$tag) {
|
||||
throw new AdminException('标签不存在');
|
||||
}
|
||||
|
||||
$tag->sort = $sort;
|
||||
$tag->save();
|
||||
|
||||
return $tag->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 为标签分配权限
|
||||
*/
|
||||
public function assignPermissions(int $tagId, array $permissionCodes): bool
|
||||
{
|
||||
// 验证标签存在
|
||||
$tag = $this->dao->get($tagId);
|
||||
if (!$tag) {
|
||||
throw new AdminException('标签不存在');
|
||||
}
|
||||
|
||||
// 验证权限代码是否存在于 web_menu 表中(code 不为空)
|
||||
if (!empty($permissionCodes)) {
|
||||
$validCodes = Menu::whereIn('code', $permissionCodes)
|
||||
->whereNotNull('code')
|
||||
->where('code', '<>', '')
|
||||
->where('enabled', 1)
|
||||
->pluck('code')
|
||||
->toArray();
|
||||
|
||||
if (count($validCodes) !== count($permissionCodes)) {
|
||||
$invalidCodes = array_diff($permissionCodes, $validCodes);
|
||||
throw new AdminException('以下权限码不存在或已禁用: ' . implode(', ', $invalidCodes));
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 belongsToMany 同步权限(标准 pivot 操作)
|
||||
$tagModel = MemberTag::find($tagId);
|
||||
if (!$tagModel) {
|
||||
throw new AdminException('标签不存在');
|
||||
}
|
||||
|
||||
$tagModel->permissions()->sync($validCodes);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员的所有权限
|
||||
*/
|
||||
public function getMemberPermissions(int $memberId): array
|
||||
{
|
||||
$member = MemberDao::make()->get($memberId);
|
||||
if (!$member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 查询会员的所有启用标签及权限
|
||||
$tags = MemberTag::with(['permissions'])
|
||||
->whereHas('members', function ($query) use ($memberId) {
|
||||
$query->where('member_id', $memberId);
|
||||
})
|
||||
->where('enabled', 1)
|
||||
->get();
|
||||
|
||||
if ($tags->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 收集所有权限
|
||||
$permissions = [];
|
||||
foreach ($tags as $tag) {
|
||||
foreach ($tag->permissions as $permission) {
|
||||
$permissions[] = $permission->code;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的标签(含权限)
|
||||
*/
|
||||
public function getEnabledTagsWithPermissions(): array
|
||||
{
|
||||
return MemberTag::with('permissions')
|
||||
->where('enabled', 1)
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的权限码(从 web_menu 表)
|
||||
*/
|
||||
public function getAvailablePermissions(): array
|
||||
{
|
||||
return Menu::whereNotNull('code')
|
||||
->where('code', '<>', '')
|
||||
->where('enabled', 1)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'code', 'name', 'url', 'category'])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\member;
|
||||
|
||||
use app\dao\member\MemberWithdrawDao;
|
||||
use app\enum\member\WithdrawStatus;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 提现管理服务
|
||||
*/
|
||||
class MemberWithdrawAdminService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
public function __construct(MemberWithdrawDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待审核提现列表
|
||||
*/
|
||||
public function getPendingWithdraws(array $params = [])
|
||||
{
|
||||
return $this->dao->getPendingWithdraws($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核提现
|
||||
*/
|
||||
public function auditWithdraw(int $withdrawId, int $status, string $auditRemark = '')
|
||||
{
|
||||
$withdraw = $this->dao->find($withdrawId);
|
||||
if (!$withdraw) {
|
||||
throw new \Exception('提现记录不存在');
|
||||
}
|
||||
|
||||
if (!in_array($status, [WithdrawStatus::APPROVED->value, WithdrawStatus::REJECTED->value])) {
|
||||
throw new \Exception('审核状态无效');
|
||||
}
|
||||
|
||||
return $this->dao->updateStatus($withdraw, $status, $auditRemark);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提现记录详情
|
||||
*/
|
||||
public function getWithdrawDetail(int $withdrawId)
|
||||
{
|
||||
$withdraw = $this->dao->find($withdrawId);
|
||||
if (!$withdraw) {
|
||||
throw new \Exception('提现记录不存在');
|
||||
}
|
||||
|
||||
return $withdraw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提现状态
|
||||
*/
|
||||
public function updateWithdrawStatus(int $withdrawId, int $status)
|
||||
{
|
||||
$withdraw = $this->dao->find($withdrawId);
|
||||
if (!$withdraw) {
|
||||
throw new \Exception('提现记录不存在');
|
||||
}
|
||||
|
||||
return $this->dao->updateStatus($withdraw, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提现统计
|
||||
*/
|
||||
public function getWithdrawStats(array $params = [])
|
||||
{
|
||||
$totalApply = $this->dao->getCount($params);
|
||||
$totalSuccess = $this->dao->getCount(array_merge($params, ['status' => WithdrawStatus::COMPLETED->value]));
|
||||
$totalRejected = $this->dao->getCount(array_merge($params, ['status' => WithdrawStatus::REJECTED->value]));
|
||||
|
||||
return [
|
||||
'total_apply' => $totalApply,
|
||||
'total_success' => $totalSuccess,
|
||||
'total_rejected' => $totalRejected,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\message;
|
||||
|
||||
use app\dao\message\MessageDao;
|
||||
use app\enum\system\BusinessPlatform;
|
||||
use app\enum\system\MessageEvent;
|
||||
use app\enum\system\MessageStatus;
|
||||
use core\base\BaseService;
|
||||
use core\notify\enum\PushClientType;
|
||||
use core\notify\Notification;
|
||||
use madong\helper\Arr;
|
||||
|
||||
/**
|
||||
* @method save(array $data)
|
||||
*/
|
||||
class MessageService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(MessageDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统登录-推送消息
|
||||
*
|
||||
* @param string|int|array $id
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function notifyOnFirstLoginToAll(string|int|array $id): void
|
||||
{
|
||||
try {
|
||||
// 获取历史消息记录
|
||||
$models = $this->selectList(['status' => MessageStatus::UNREAD->value], '*', 0, 0, '', [])->toArray();
|
||||
if (!empty($models)) {
|
||||
foreach ($models as $value) {
|
||||
Notification::pushOnly(PushClientType::BACKEND, BusinessPlatform::ADMIN->value, '*', $value['receiver_id'], MessageEvent::DEFAULT->value, [], $value,null);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已读
|
||||
*
|
||||
* @param string|array $param
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function isRead(string|array $param): void
|
||||
{
|
||||
try {
|
||||
$data = Arr::normalize($param);
|
||||
$messageModel = $this->dao->getModel();
|
||||
$models = $messageModel->find($data);
|
||||
foreach ($models as $model) {
|
||||
$model->status = MessageStatus::READ;
|
||||
$model->read_at = time();
|
||||
$model->save();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记未读
|
||||
*
|
||||
* @param string|array $param
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function isUnread(string|array $param): void
|
||||
{
|
||||
try {
|
||||
$data = Arr::normalize($param);
|
||||
$messageModel = $this->dao->getModel();
|
||||
$models = $messageModel->find($data);
|
||||
foreach ($models as $model) {
|
||||
$model->status = MessageStatus::UNREAD;
|
||||
$model->updated_at = time();
|
||||
$model->save();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\monitor;
|
||||
|
||||
use app\model\system\Admin;
|
||||
use app\scope\global\AccessPermissionScope;
|
||||
use core\base\BaseService;
|
||||
use core\jwt\JwtToken;
|
||||
|
||||
class OnlineUserService extends BaseService
|
||||
{
|
||||
public function list(array $params = []): array
|
||||
{
|
||||
$page = max(1, (int)($params['page'] ?? 1));
|
||||
$limit = max(1, (int)($params['limit'] ?? 10));
|
||||
$keyword = trim((string)($params['keyword'] ?? ''));
|
||||
$ip = trim((string)($params['ip'] ?? ''));
|
||||
$clientType = trim((string)($params['client_type'] ?? 'admin'));
|
||||
$status = trim((string)($params['status'] ?? ''));
|
||||
|
||||
$jwt = new JwtToken();
|
||||
$sessions = $jwt->getAllSessions($clientType !== '' ? $clientType : null);
|
||||
$userIds = array_values(array_unique(array_filter(array_map(static fn (array $session): string => (string)($session['id'] ?? ''), $sessions))));
|
||||
$users = $this->getUsersByIds($userIds);
|
||||
|
||||
$items = [];
|
||||
foreach ($sessions as $session) {
|
||||
$extra = $session['extra'] ?? [];
|
||||
$userId = (string)($session['id'] ?? '');
|
||||
$user = $users[$userId] ?? null;
|
||||
$account = $extra['user_name'] ?? $user['user_name'] ?? '';
|
||||
$realName = $extra['real_name'] ?? $user['real_name'] ?? '';
|
||||
$sessionIp = $extra['ip'] ?? $user['login_ip'] ?? '';
|
||||
$roleNames = $user['role_names'] ?? $this->formatExtraNames($extra['roles'] ?? []);
|
||||
if ($roleNames === '' && (int)($extra['is_super'] ?? 0) === 1) {
|
||||
$roleNames = '超级管理员';
|
||||
}
|
||||
|
||||
if ($keyword !== '' && !str_contains($account, $keyword) && !str_contains($realName, $keyword)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ip !== '' && !str_contains($sessionIp, $ip)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sessionStatus = (string)($session['status'] ?? 'online');
|
||||
if ($jwt->getBlacklistStorage()->has((string)($session['jti'] ?? ''))) {
|
||||
$sessionStatus = 'offline';
|
||||
}
|
||||
if ($status !== '' && $sessionStatus !== $status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createdAt = (int)($session['created_at'] ?? $user['login_time'] ?? time());
|
||||
$lastActiveAt = (int)($session['last_active_at'] ?? $createdAt);
|
||||
$offlineAt = isset($session['offline_at']) ? (int)$session['offline_at'] : null;
|
||||
$durationEndAt = $sessionStatus === 'offline' && $offlineAt !== null ? $offlineAt : time();
|
||||
$items[] = [
|
||||
'jti' => $session['jti'],
|
||||
'user_id' => $userId,
|
||||
'account' => $account,
|
||||
'real_name' => $realName,
|
||||
'dept_names' => $user['dept_names'] ?? '',
|
||||
'post_names' => $user['post_names'] ?? '',
|
||||
'role_names' => $roleNames,
|
||||
'ip' => $sessionIp,
|
||||
'ip_location' => $extra['ip_location'] ?? '',
|
||||
'device' => $extra['os'] ?? $this->getCurrentRequestOs(),
|
||||
'browser' => $extra['browser'] ?? $this->getCurrentRequestBrowser(),
|
||||
'status' => $sessionStatus,
|
||||
'client_type' => $session['client_type'] ?? '',
|
||||
'login_time' => $createdAt,
|
||||
'last_active_at' => $lastActiveAt,
|
||||
'offline_at' => $offlineAt,
|
||||
'online_seconds' => max(0, $durationEndAt - $createdAt),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b): int => ($b['last_active_at'] ?? 0) <=> ($a['last_active_at'] ?? 0));
|
||||
|
||||
$total = count($items);
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
return [
|
||||
'items' => array_slice($items, $offset, $limit),
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
public function kickout(string $jti): bool
|
||||
{
|
||||
return (new JwtToken())->kickoutByJti($jti);
|
||||
}
|
||||
|
||||
protected function getUsersByIds(array $ids): array
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Admin::query()
|
||||
->withoutGlobalScopes([AccessPermissionScope::class])
|
||||
->with(['depts', 'posts', 'roles'])
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
$users = [];
|
||||
foreach ($rows as $row) {
|
||||
$users[(string)$row->id] = [
|
||||
'user_name' => $row->user_name,
|
||||
'real_name' => $row->real_name,
|
||||
'is_super' => $row->is_super,
|
||||
'login_ip' => $row->login_ip,
|
||||
'login_time' => $row->login_time,
|
||||
'dept_names' => $row->depts->pluck('name')->implode(' / '),
|
||||
'post_names' => $row->posts->pluck('name')->implode(' / '),
|
||||
'role_names' => $row->roles->pluck('name')->implode(' / ') ?: ((int)$row->is_super === 1 ? '超级管理员' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
protected function formatExtraNames(array $items): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item) && !empty($item['name'])) {
|
||||
$names[] = $item['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' / ', $names);
|
||||
}
|
||||
|
||||
protected function getCurrentRequestBrowser(): string
|
||||
{
|
||||
$userAgent = request()?->header('user-agent', '') ?? '';
|
||||
if (preg_match('/Edg/i', $userAgent)) {
|
||||
return 'Edge';
|
||||
}
|
||||
if (preg_match('/Firefox/i', $userAgent)) {
|
||||
return 'Firefox';
|
||||
}
|
||||
if (preg_match('/Chrome/i', $userAgent)) {
|
||||
return 'Chrome';
|
||||
}
|
||||
if (preg_match('/Safari/i', $userAgent)) {
|
||||
return 'Safari';
|
||||
}
|
||||
if (preg_match('/Opera/i', $userAgent)) {
|
||||
return 'Opera';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function getCurrentRequestOs(): string
|
||||
{
|
||||
$userAgent = request()?->header('user-agent', '') ?? '';
|
||||
if (preg_match('/win/i', $userAgent)) {
|
||||
return 'Windows';
|
||||
}
|
||||
if (preg_match('/mac/i', $userAgent)) {
|
||||
return 'Mac';
|
||||
}
|
||||
if (preg_match('/linux/i', $userAgent)) {
|
||||
return 'Linux';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\notice;
|
||||
|
||||
use app\dao\notice\NoticeDao;
|
||||
use app\model\notice\Notice;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\uuid\UUIDGenerator;
|
||||
|
||||
class NoticeService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(NoticeDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return \app\model\notice\Notice|null
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function save(array $data): ?Notice
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($data) {
|
||||
$data['uuid'] = UUIDGenerator::generate();
|
||||
return $this->dao->save($data);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新
|
||||
*
|
||||
* @param string|int $id
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update(string|int $id, array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$data['uuid'] = UUIDGenerator::generate();//更新之后变更uuid 再次推送重新发起
|
||||
$model = $this->dao->get($id);
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\org;
|
||||
|
||||
use app\dao\org\DeptLeaderDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class DeptLeaderService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(DeptLeaderDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\org;
|
||||
|
||||
use app\dao\org\DeptDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* @method getChildIdsIncludingSelf(mixed $deptId)
|
||||
*/
|
||||
class DeptService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(DeptDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* save
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function save(array $data): mixed
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($data) {
|
||||
$leaders = $data['leader_id_list'] ?? [];//部门领导
|
||||
$model = $this->dao->save($data);
|
||||
if (!empty($leaders)) {
|
||||
$model->leader()->sync($leaders);
|
||||
}
|
||||
return $model;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* update
|
||||
*
|
||||
* @param $id
|
||||
* @param $data
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update($id, $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$leaders = $data['leader_id_list'] ?? [];//部门领导
|
||||
unset($data['leader_id_list']);
|
||||
$this->dao->update($id, $data);
|
||||
$model = $this->get($id);
|
||||
$model->leader()->sync($leaders);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy
|
||||
*
|
||||
* @param $id
|
||||
* @param $force
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function destroy($id, $force): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $force) {
|
||||
$res = $this->dao->count(['pid' => $id], true);
|
||||
if ($res > 0) {
|
||||
throw new AdminException('该部门下存在子部门,请先删除子部门');
|
||||
}
|
||||
$this->dao->destroy($id);
|
||||
$systemDeptLeaderService = Container::make(DeptLeaderService::class);
|
||||
$systemDeptLeaderService->dao->delete(['dept_id' => $id]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\org;
|
||||
|
||||
use app\dao\org\PostDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* @method save(array $data)
|
||||
*/
|
||||
class PostService extends BaseService
|
||||
{
|
||||
|
||||
|
||||
public function __construct(PostDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\plugin;
|
||||
|
||||
use app\dao\plugin\ApplicationAuthDao;
|
||||
use app\model\plugin\ApplicationAuth;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
|
||||
class ApplicationAuthService extends BaseService
|
||||
{
|
||||
public function __construct(ApplicationAuthDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
public function save(array $data): ?ApplicationAuth
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($data) {
|
||||
$data['access_key'] = $data['access_key'] ?? $this->generateAccessKey();
|
||||
$data['secret_key'] = $data['secret_key'] ?? $this->generateSecretKey();
|
||||
return $this->dao->save($data);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function update(string|int $id, array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
unset($data['access_key'], $data['secret_key']);
|
||||
$model = $this->dao->get($id);
|
||||
if (!$model) {
|
||||
throw new AdminException('应用授权不存在');
|
||||
}
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function resetSecret(string|int $id): array
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($id) {
|
||||
$model = $this->dao->get($id);
|
||||
if (!$model) {
|
||||
throw new AdminException('应用授权不存在');
|
||||
}
|
||||
$model->secret_key = $this->generateSecretKey();
|
||||
$model->save();
|
||||
return [
|
||||
'id' => (string)$model->id,
|
||||
'secret_key' => $model->secret_key,
|
||||
];
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function generateAccessKey(): string
|
||||
{
|
||||
return 'AK' . strtoupper(bin2hex(random_bytes(16)));
|
||||
}
|
||||
|
||||
private function generateSecretKey(): string
|
||||
{
|
||||
return 'SK' . bin2hex(random_bytes(32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\plugin;
|
||||
|
||||
use app\dao\plugin\PluginDao;
|
||||
use app\model\plugin\Plugin;
|
||||
use app\process\Monitor;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\uuid\Snowflake;
|
||||
use support\Container;
|
||||
use Workerman\Timer;
|
||||
|
||||
/**
|
||||
* Plugin服务层
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class PluginDevelopService extends BaseService
|
||||
{
|
||||
private const MONITOR_RESUME_DELAY = 3;
|
||||
|
||||
private string $pluginDir;
|
||||
private string $adminDir;
|
||||
private string $webDir;
|
||||
|
||||
public function __construct(PluginDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
$basePath = base_path();
|
||||
$projectPath = dirname($basePath);
|
||||
$frontendPath = $projectPath . '/frontend';
|
||||
|
||||
$this->pluginDir = $basePath . '/plugin';
|
||||
$this->adminDir = $frontendPath . '/admin/src/apps';
|
||||
$this->webDir = $frontendPath . '/web/app/apps';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件列表(扫描插件目录并同步到数据库)
|
||||
*
|
||||
* @param array $where 查询条件(标准filters格式)
|
||||
* @param int $page 页码
|
||||
* @param int $limit 每页数量
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getList(array $where = [], int $page = 1, int $limit = 15): array
|
||||
{
|
||||
// 先扫描插件目录,将新插件同步到数据库
|
||||
$this->syncPlugins();
|
||||
return $this->dao->getList($where, $page, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描插件目录并同步到数据库
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function syncPlugins(): void
|
||||
{
|
||||
// 获取插件根目录下所有子目录
|
||||
if (!is_dir($this->pluginDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dirs = scandir($this->pluginDir);
|
||||
$pluginsToInsert = [];
|
||||
$existingPluginKeys = [];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
// 跳过.和..目录
|
||||
if ($dir === '.' || $dir === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 优先读取 info.php 获取插件基本信息
|
||||
$infoPath = $this->pluginDir . '/' . $dir . '/config/info.php';
|
||||
if (!file_exists($infoPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 加载插件 info 配置
|
||||
$infoConfig = include $infoPath;
|
||||
if (!is_array($infoConfig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查插件归属类型:madong(官方)、third(第三方)、custom(自定义)
|
||||
$pluginType = $infoConfig['type'] ?? 'madong';
|
||||
|
||||
// 检查是否为madong类型的插件
|
||||
if (!str_starts_with($pluginType, 'madong:') && $pluginType !== 'madong') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pluginKey = $infoConfig['name'] ?? $dir;
|
||||
$existingPluginKeys[] = $pluginKey;
|
||||
|
||||
// 检查 installed.php 判断是否已安装
|
||||
$installedPath = $this->pluginDir . '/' . $dir . '/config/installed.php';
|
||||
$isInstalled = file_exists($installedPath);
|
||||
$installedAt = 0;
|
||||
$status = 0; // 默认未安装
|
||||
|
||||
if ($isInstalled) {
|
||||
$installedConfig = include $installedPath;
|
||||
if (is_array($installedConfig) && !empty($installedConfig['installed_at'])) {
|
||||
$installedAt = strtotime($installedConfig['installed_at']);
|
||||
} else {
|
||||
$installedAt = time();
|
||||
}
|
||||
$status = 1; // 已安装
|
||||
}
|
||||
|
||||
// 检查插件是否已存在于数据库
|
||||
$existingPlugin = $this->dao->findByKey($pluginKey);
|
||||
if ($existingPlugin) {
|
||||
// 如果数据库存在但安装状态不同,同步更新
|
||||
if ($existingPlugin->installed_at != $installedAt || $existingPlugin->status != $status) {
|
||||
$existingPlugin->installed_at = $installedAt;
|
||||
$existingPlugin->status = $status;
|
||||
$existingPlugin->save();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 读取icon和cover图片(base64或路径)
|
||||
$icon = $this->getPluginImageBase64($dir, 'icon');
|
||||
$cover = $this->getPluginImageBase64($dir, 'cover');
|
||||
|
||||
// 使用 info.php 的字段
|
||||
$pluginsToInsert[] = [
|
||||
'id' => Snowflake::generate(),
|
||||
'title' => $infoConfig['description'] ?? $pluginKey,
|
||||
'key' => $pluginKey,
|
||||
'desc' => $infoConfig['description'] ?? '',
|
||||
'author' => $infoConfig['author'] ?? '',
|
||||
'version' => $infoConfig['version'] ?? '1.0.0',
|
||||
'type' => $pluginType,
|
||||
'icon' => $icon,
|
||||
'cover' => $cover,
|
||||
'status' => $status,
|
||||
'support_app' => 'admin',
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
'installed_at' => $installedAt,
|
||||
];
|
||||
}
|
||||
// 批量插入新插件
|
||||
if (!empty($pluginsToInsert)) {
|
||||
$this->dao->batchInsert($pluginsToInsert);
|
||||
}
|
||||
// 移除数据库中已不存在的插件
|
||||
$this->removeNonExistingPlugins($existingPluginKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除数据库中已不存在的插件
|
||||
*
|
||||
* @param array $existingPluginKeys 现有的插件key列表
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function removeNonExistingPlugins(array $existingPluginKeys): void
|
||||
{
|
||||
$allDbPluginKeys = $this->dao->getAllKeys();
|
||||
$keysToDelete = array_diff($allDbPluginKeys, $existingPluginKeys);
|
||||
|
||||
if (!empty($keysToDelete)) {
|
||||
// 批量删除不存在的插件
|
||||
foreach ($keysToDelete as $key) {
|
||||
$plugin = $this->dao->findByKey($key);
|
||||
if ($plugin) {
|
||||
$plugin->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件图片的base64编码
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $imageName 图片名称(icon或cover)
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
private function getPluginImageBase64(string $pluginName, string $imageName): ?string
|
||||
{
|
||||
$imagePath = $this->pluginDir . '/' . $pluginName . '/public/' . $imageName . '.png';
|
||||
if (!file_exists($imagePath)) {
|
||||
// 尝试查找resource目录下的图片
|
||||
$resourcePath = $this->pluginDir . '/' . $pluginName . '/resource/public/' . $imageName . '.png';
|
||||
if (!file_exists($resourcePath)) {
|
||||
return null;
|
||||
}
|
||||
$imagePath = $resourcePath;
|
||||
}
|
||||
$imageData = file_get_contents($imagePath);
|
||||
if ($imageData === false) {
|
||||
return null;
|
||||
}
|
||||
return 'data:image/png;base64,' . base64_encode($imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件详情
|
||||
*
|
||||
* @param string|int $id 插件ID
|
||||
*
|
||||
* @return Plugin|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function read(string|int $id): ?Plugin
|
||||
{
|
||||
$plugin = $this->dao->get($id);
|
||||
if (!$plugin) {
|
||||
return null;
|
||||
}
|
||||
$pluginConfigPath = $this->pluginDir . '/' . $plugin->key . '/config/app.php';
|
||||
if (file_exists($pluginConfigPath)) {
|
||||
$config = include $pluginConfigPath;
|
||||
if (is_array($config)) {
|
||||
// 更新插件信息
|
||||
$plugin->title = $config['title'] ?? $plugin->title;
|
||||
$plugin->desc = $config['description'] ?? $plugin->desc;
|
||||
$plugin->author = $config['author'] ?? $plugin->author;
|
||||
$plugin->version = $config['version'] ?? $plugin->version;
|
||||
$plugin->type = $config['type'] ?? $plugin->type;
|
||||
$plugin->icon = $this->getPluginImageBase64($plugin->key, 'icon');
|
||||
$plugin->cover = $this->getPluginImageBase64($plugin->key, 'cover');
|
||||
}
|
||||
}
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return void
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function store(array $data): void
|
||||
{
|
||||
$this->transaction(function () use ($data) {
|
||||
try {
|
||||
if (empty($data['icon'])) {
|
||||
$data['icon'] = $this->generateDefaultIcon($data['title'] ?? 'Plugin');
|
||||
}
|
||||
if (empty($data['cover'])) {
|
||||
$data['cover'] = $this->generateDefaultCover($data['title'] ?? 'Plugin');
|
||||
}
|
||||
$model = $this->dao->save($data);
|
||||
if (empty($model)) {
|
||||
throw new AdminException('插件创建失败');
|
||||
}
|
||||
|
||||
$monitor_support_pause = method_exists(Monitor::class, 'pause');
|
||||
if ($monitor_support_pause) {
|
||||
Monitor::pause();
|
||||
}
|
||||
//生成插件模板
|
||||
/**@var \app\service\core\plugin\PluginDevelopService $service */
|
||||
$service = Container::make(\app\service\core\plugin\PluginDevelopService::class);
|
||||
$pluginName = $model->key;
|
||||
$pluginTitle = $model->title;
|
||||
$pluginDescription = $model->desc ?? '';
|
||||
$frontendType = $data['frontend_type'] ?? 'admin';
|
||||
$result = $service->generatePluginTemplate($pluginName, $pluginTitle, $pluginDescription, $frontendType);
|
||||
if ($result['code'] !== 200) {
|
||||
throw new AdminException($result['message']);
|
||||
}
|
||||
|
||||
// 生成插件 info.php 配置文件
|
||||
$this->createPluginInfoConfig($pluginName, $data);
|
||||
|
||||
//添加图片到对应插件位置
|
||||
$publicPath = $this->pluginDir . '/' . $pluginName . '/public';
|
||||
// 确保public目录存在
|
||||
if (!is_dir($publicPath)) {
|
||||
mkdir($publicPath, 0755, true);
|
||||
}
|
||||
$iconPath = $publicPath . '/icon.png';
|
||||
$coverPath = $publicPath . '/cover.png';
|
||||
$this->saveBase64Image($data['icon'], $iconPath);
|
||||
$this->saveBase64Image($data['cover'], $coverPath);
|
||||
if ($monitor_support_pause) {
|
||||
Timer::add(self::MONITOR_RESUME_DELAY, static function () {
|
||||
Monitor::resume();
|
||||
}, null, false);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包插件
|
||||
*
|
||||
* @param string $pluginKey 插件标识
|
||||
* @param bool $updateTemplate 是否更新前端模板到resource/template目录(默认true)
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function buildPlugin(string $pluginKey, bool $updateTemplate = true): array
|
||||
{
|
||||
try {
|
||||
// 查询插件信息
|
||||
$plugin = $this->dao->findByKey($pluginKey);
|
||||
if (!$plugin) {
|
||||
throw new AdminException('插件不存在');
|
||||
}
|
||||
|
||||
// 定义路径
|
||||
$backendPath = $this->pluginDir . '/' . $pluginKey;
|
||||
$adminPath = $this->adminDir . '/' . $pluginKey;
|
||||
$webPath = $this->webDir . '/' . $pluginKey;
|
||||
$resourcePath = $backendPath . '/resource/template';
|
||||
|
||||
// 验证后端插件目录是否存在
|
||||
if (!is_dir($backendPath)) {
|
||||
throw new AdminException("插件后端目录不存在: {$backendPath}");
|
||||
}
|
||||
|
||||
// 是否更新前端模板到 resource/template 目录
|
||||
if ($updateTemplate) {
|
||||
// 清理并创建template目录
|
||||
if (is_dir($resourcePath)) {
|
||||
$this->removeDirectory($resourcePath);
|
||||
}
|
||||
|
||||
$resourceCount = 0;
|
||||
|
||||
// 复制后台前端到template目录(如果存在)
|
||||
if (is_dir($adminPath)) {
|
||||
$this->copyDirectory($adminPath, $resourcePath . '/admin');
|
||||
$resourceCount++;
|
||||
}
|
||||
|
||||
// 复制前台前端到resource目录(如果存在)
|
||||
if (is_dir($webPath)) {
|
||||
$this->copyDirectory($webPath, $resourcePath . '/web');
|
||||
$resourceCount++;
|
||||
}
|
||||
|
||||
if ($resourceCount === 0) {
|
||||
// 没有前端资源,清空resource目录
|
||||
if (is_dir($resourcePath)) {
|
||||
rmdir($resourcePath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不更新时检查是否已有模板
|
||||
$resourceCount = (is_dir($resourcePath . '/admin') || is_dir($resourcePath . '/web')) ? 1 : 0;
|
||||
}
|
||||
|
||||
// 调用核心服务的 build 方法打包插件
|
||||
$pluginsDir = base_path() . '/runtime/plugins';
|
||||
/** @var \app\service\core\plugin\PluginDevelopService $coreService */
|
||||
$coreService = Container::make(\app\service\core\plugin\PluginDevelopService::class);
|
||||
$result = $coreService->build($pluginKey, $pluginsDir);
|
||||
|
||||
if ($result['code'] !== 200) {
|
||||
throw new AdminException($result['message']);
|
||||
}
|
||||
|
||||
return [
|
||||
'plugin_key' => $pluginKey,
|
||||
'zip_path' => $result['data']['zip_file_path'],
|
||||
'has_frontend' => $resourceCount > 0,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException('插件打包失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录
|
||||
*
|
||||
* @param string $source 源目录
|
||||
* @param string $target 目标目录
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function copyDirectory(string $source, string $target): void
|
||||
{
|
||||
if (!is_dir($source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
|
||||
$files = scandir($source);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceFile = $source . '/' . $file;
|
||||
$targetFile = $target . '/' . $file;
|
||||
|
||||
if (is_dir($sourceFile)) {
|
||||
$this->copyDirectory($sourceFile, $targetFile);
|
||||
} else {
|
||||
copy($sourceFile, $targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录及其内容
|
||||
*
|
||||
* @param string $dir 目录路径
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($dir);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir . '/' . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->removeDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件信息
|
||||
*
|
||||
* @param int|string $id 插件ID
|
||||
* @param array $data 更新数据
|
||||
*
|
||||
* @return void
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update(int|string $id, array $data): void
|
||||
{
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$model = $this->dao->get($id);
|
||||
if (!$model) {
|
||||
throw new AdminException('插件不存在');
|
||||
}
|
||||
|
||||
$pluginKey = $model->getAttribute('key');
|
||||
|
||||
// 更新数据库 - 排除key字段,不允许修改插件标识
|
||||
$updateData = $data;
|
||||
unset($updateData['key']);
|
||||
$model->fill($updateData);
|
||||
$model->save();
|
||||
|
||||
// 更新插件配置文件 app.php
|
||||
$this->updatePluginConfig($pluginKey, $data);
|
||||
|
||||
// 添加图片到对应插件位置(仅当提供了新的base64图片时)
|
||||
if (isset($data['icon']) || isset($data['cover'])) {
|
||||
$pluginDirName = $this->findPluginDirectory($pluginKey);
|
||||
if ($pluginDirName) {
|
||||
$publicPath = $this->pluginDir . '/' . $pluginDirName . '/public';
|
||||
// 确保public目录存在
|
||||
if (!is_dir($publicPath)) {
|
||||
mkdir($publicPath, 0755, true);
|
||||
}
|
||||
|
||||
if (!empty($data['icon'])) {
|
||||
$iconPath = $publicPath . '/icon.png';
|
||||
$this->saveBase64Image($data['icon'], $iconPath);
|
||||
}
|
||||
|
||||
if (!empty($data['cover'])) {
|
||||
$coverPath = $publicPath . '/cover.png';
|
||||
$this->saveBase64Image($data['cover'], $coverPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据插件key查找实际的目录名
|
||||
*
|
||||
* @param string $pluginKey 插件key
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
private function findPluginDirectory(string $pluginKey): ?string
|
||||
{
|
||||
if (!is_dir($this->pluginDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dirs = scandir($this->pluginDir);
|
||||
foreach ($dirs as $dir) {
|
||||
if ($dir === '.' || $dir === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 优先使用 info.php 查找插件key
|
||||
$infoPath = $this->pluginDir . '/' . $dir . '/config/info.php';
|
||||
if (file_exists($infoPath)) {
|
||||
$infoConfig = include $infoPath;
|
||||
if (is_array($infoConfig) && isset($infoConfig['name']) && $infoConfig['name'] === $pluginKey) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 info.php 不存在或找不到,尝试从 app.php 查找(旧版本兼容)
|
||||
$configPath = $this->pluginDir . '/' . $dir . '/config/app.php';
|
||||
if (!file_exists($configPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = include $configPath;
|
||||
if (!is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($config['name'] ?? '') === $pluginKey) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件配置文件
|
||||
*
|
||||
* @param string $pluginKey 插件标识
|
||||
* @param array $data 更新数据
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function updatePluginConfig(string $pluginKey, array $data): void
|
||||
{
|
||||
// 查找实际的插件目录名
|
||||
$pluginDirName = $this->findPluginDirectory($pluginKey);
|
||||
if (!$pluginDirName) {
|
||||
throw new AdminException('插件目录不存在: ' . $pluginKey);
|
||||
}
|
||||
$configPath = $this->pluginDir . '/' . $pluginDirName . '/config/app.php';
|
||||
// 检查配置文件是否存在
|
||||
if (!file_exists($configPath)) {
|
||||
throw new AdminException('插件配置文件不存在: ' . $configPath);
|
||||
}
|
||||
// 加载当前配置
|
||||
$config = include $configPath;
|
||||
if (!is_array($config)) {
|
||||
throw new AdminException('插件配置文件格式错误');
|
||||
}
|
||||
// 更新配置项
|
||||
$fieldMapping = [
|
||||
'title' => 'title',
|
||||
'desc' => 'description',
|
||||
'author' => 'author',
|
||||
'version' => 'version',
|
||||
'type' => 'type',
|
||||
'status' => 'enable',
|
||||
];
|
||||
|
||||
foreach ($fieldMapping as $dbField => $configField) {
|
||||
if (isset($data[$dbField])) {
|
||||
if ($dbField === 'status') {
|
||||
// status字段转换为enable布尔值
|
||||
$config[$configField] = (bool)$data[$dbField];
|
||||
} else {
|
||||
$config[$configField] = $data[$dbField];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成PHP配置文件内容
|
||||
$phpContent = "<?php\n\nreturn " . $this->arrayToPhpCode($config) . ";\n";
|
||||
|
||||
// 写入配置文件
|
||||
file_put_contents($configPath, $phpContent);
|
||||
|
||||
// 同时更新 info.php 配置文件
|
||||
$this->updatePluginInfoConfig($pluginDirName, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件 info.php 配置文件
|
||||
*
|
||||
* @param string $pluginKey 插件标识
|
||||
* @param array $data 插件数据
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function createPluginInfoConfig(string $pluginKey, array $data): void
|
||||
{
|
||||
$pluginDir = $this->pluginDir . '/' . $pluginKey;
|
||||
$configDir = $pluginDir . '/config';
|
||||
|
||||
// 确保 config 目录存在
|
||||
if (!is_dir($configDir)) {
|
||||
mkdir($configDir, 0755, true);
|
||||
}
|
||||
|
||||
$infoConfig = [
|
||||
'name' => $pluginKey,
|
||||
'identifier' => $pluginKey,
|
||||
'type' => $data['type'] ?? 'madong', // 插件归属类型:madong(官方)、third(第三方)、custom(自定义)
|
||||
'version' => $data['version'] ?? '1.0.0',
|
||||
'description' => $data['desc'] ?? '',
|
||||
'author' => $data['author'] ?? '',
|
||||
'author_email' => $data['author_email'] ?? '',
|
||||
'website' => $data['website'] ?? '',
|
||||
'uninstall' => [
|
||||
'drop_tables' => false,
|
||||
'remove_dependencies' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$phpContent = "<?php\n\n/**\n * 插件信息配置\n */\n\nreturn " . $this->arrayToPhpCode($infoConfig) . ";\n";
|
||||
file_put_contents($configDir . '/info.php', $phpContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件 info.php 配置文件
|
||||
*
|
||||
* @param string $pluginDirName 插件目录名
|
||||
* @param array $data 更新数据
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function updatePluginInfoConfig(string $pluginDirName, array $data): void
|
||||
{
|
||||
$infoPath = $this->pluginDir . '/' . $pluginDirName . '/config/info.php';
|
||||
|
||||
// 如果 info.php 不存在,则创建
|
||||
if (!file_exists($infoPath)) {
|
||||
$this->createPluginInfoConfig($pluginDirName, $data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载当前配置
|
||||
$infoConfig = include $infoPath;
|
||||
if (!is_array($infoConfig)) {
|
||||
$infoConfig = [];
|
||||
}
|
||||
|
||||
// 更新配置项
|
||||
$fieldMapping = [
|
||||
'type' => 'type',
|
||||
'version' => 'version',
|
||||
'desc' => 'description',
|
||||
'author' => 'author',
|
||||
'author_email' => 'author_email',
|
||||
'website' => 'website',
|
||||
];
|
||||
|
||||
foreach ($fieldMapping as $dbField => $infoField) {
|
||||
if (isset($data[$dbField])) {
|
||||
$infoConfig[$infoField] = $data[$dbField];
|
||||
}
|
||||
}
|
||||
|
||||
// 确保必要的字段存在
|
||||
if (!isset($infoConfig['name'])) {
|
||||
$infoConfig['name'] = $pluginDirName;
|
||||
}
|
||||
if (!isset($infoConfig['identifier'])) {
|
||||
$infoConfig['identifier'] = $pluginDirName;
|
||||
}
|
||||
if (!isset($infoConfig['type'])) {
|
||||
$infoConfig['type'] = 'madong'; // 默认归属 madong
|
||||
}
|
||||
if (!isset($infoConfig['uninstall'])) {
|
||||
$infoConfig['uninstall'] = [
|
||||
'drop_tables' => false,
|
||||
'remove_dependencies' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// 生成PHP配置文件内容
|
||||
$phpContent = "<?php\n\n/**\n * 插件信息配置\n */\n\nreturn " . $this->arrayToPhpCode($infoConfig) . ";\n";
|
||||
|
||||
// 写入配置文件
|
||||
file_put_contents($infoPath, $phpContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数组转换为PHP代码格式
|
||||
*
|
||||
* @param array $array 数组
|
||||
* @param int $level 缩进级别
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function arrayToPhpCode(array $array, int $level = 1): string
|
||||
{
|
||||
$indent = str_repeat(' ', $level);
|
||||
$nextIndent = str_repeat(' ', $level + 1);
|
||||
|
||||
$result = "[\n";
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if (is_numeric($key)) {
|
||||
$result .= $nextIndent;
|
||||
} else {
|
||||
$result .= $nextIndent . "'{$key}' => ";
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$result .= $this->arrayToPhpCode($value, $level + 1);
|
||||
} elseif (is_bool($value)) {
|
||||
$result .= $value ? 'true' : 'false';
|
||||
} elseif (is_null($value)) {
|
||||
$result .= 'null';
|
||||
} elseif (is_string($value)) {
|
||||
// 转义单引号
|
||||
$escaped = str_replace("'", "\\'", $value);
|
||||
$result .= "'{$escaped}'";
|
||||
} else {
|
||||
$result .= $value;
|
||||
}
|
||||
|
||||
$result .= ",\n";
|
||||
}
|
||||
|
||||
$result .= $indent . ']';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存base64图片到文件
|
||||
*
|
||||
* @param string $base64 base64编码的图片
|
||||
* @param string $targetPath 目标路径
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function saveBase64Image(string $base64, string $targetPath): void
|
||||
{
|
||||
// 提取base64数据
|
||||
if (preg_match('/^data:image\/(\w+);base64,/', $base64, $matches)) {
|
||||
$imageData = substr($base64, strpos($base64, ',') + 1);
|
||||
$imageData = base64_decode($imageData);
|
||||
if ($imageData !== false) {
|
||||
file_put_contents($targetPath, $imageData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认图标(64x64)
|
||||
*
|
||||
* @param string $text 文字
|
||||
*
|
||||
* @return string base64编码的图片
|
||||
*/
|
||||
private function generateDefaultIcon(string $text): string
|
||||
{
|
||||
// 提取首字母
|
||||
$firstChar = mb_substr($text, 0, 1, 'UTF-8');
|
||||
|
||||
// 创建64x64的图像
|
||||
$size = 64;
|
||||
$image = imagecreatetruecolor($size, $size);
|
||||
|
||||
// 分配颜色
|
||||
$bgColor = imagecolorallocate($image, 64, 158, 255); // 蓝色背景 #409EFF
|
||||
$textColor = imagecolorallocate($image, 255, 255, 255); // 白色文字
|
||||
|
||||
// 填充背景
|
||||
imagefill($image, 0, 0, $bgColor);
|
||||
|
||||
// 绘制圆角矩形
|
||||
$radius = 12;
|
||||
$corners = [
|
||||
[0, 0], [$size - 1, 0],
|
||||
[0, $size - 1], [$size - 1, $size - 1],
|
||||
];
|
||||
|
||||
foreach ($corners as $corner) {
|
||||
imagefilledarc($image, $corner[0] + $radius, $corner[1] + $radius, $radius * 2, $radius * 2, 0, 360, $bgColor, IMG_ARC_PIE);
|
||||
}
|
||||
|
||||
// 添加文字
|
||||
$fontSize = 24;
|
||||
$fontFile = $this->getFontFile();
|
||||
if ($fontFile && file_exists($fontFile)) {
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontFile, $firstChar);
|
||||
$textWidth = $bbox[2] - $bbox[0];
|
||||
$textHeight = $bbox[7] - $bbox[1];
|
||||
$x = ($size - $textWidth) / 2 - $bbox[0];
|
||||
$y = ($size - $textHeight) / 2 - $bbox[1];
|
||||
imagettftext($image, $fontSize, 0, $x, $y, $textColor, $fontFile, $firstChar);
|
||||
} else {
|
||||
// 如果没有字体文件,使用内置字体
|
||||
imagestring($image, 5, ($size - imagefontwidth(5)) / 2, ($size - imagefontheight(5)) / 2, $firstChar, $textColor);
|
||||
}
|
||||
|
||||
// 转换为base64
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$imageData = ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认封面(200x120)
|
||||
*
|
||||
* @param string $text 文字
|
||||
*
|
||||
* @return string base64编码的图片
|
||||
*/
|
||||
private function generateDefaultCover(string $text): string
|
||||
{
|
||||
// 创建200x120的图像
|
||||
$width = 200;
|
||||
$height = 120;
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
|
||||
// 分配颜色
|
||||
$bgColor = imagecolorallocate($image, 240, 242, 245); // 浅灰色背景 #F0F2F5
|
||||
$lineColor = imagecolorallocate($image, 220, 223, 230); // 线条颜色 #DCDFE6
|
||||
$textColor = imagecolorallocate($image, 144, 147, 153); // 文字颜色 #909399
|
||||
|
||||
// 填充背景
|
||||
imagefill($image, 0, 0, $bgColor);
|
||||
|
||||
// 绘制圆角
|
||||
$radius = 8;
|
||||
$corners = [
|
||||
[0, 0], [$width - 1, 0],
|
||||
[0, $height - 1], [$width - 1, $height - 1],
|
||||
];
|
||||
|
||||
foreach ($corners as $corner) {
|
||||
imagefilledarc($image, $corner[0] + $radius, $corner[1] + $radius, $radius * 2, $radius * 2, 0, 360, $bgColor, IMG_ARC_PIE);
|
||||
}
|
||||
|
||||
// 绘制虚线边框
|
||||
imagesetthickness($image, 1);
|
||||
imagerectangle($image, 1, 1, $width - 2, $height - 2, $lineColor);
|
||||
|
||||
// 绘制中间的+号
|
||||
$plusSize = 24;
|
||||
$centerX = $width / 2;
|
||||
$centerY = $height / 2;
|
||||
|
||||
imagesetthickness($image, 2);
|
||||
imageline($image, $centerX - $plusSize, $centerY, $centerX + $plusSize, $centerY, $textColor);
|
||||
imageline($image, $centerX, $centerY - $plusSize, $centerX, $centerY + $plusSize, $textColor);
|
||||
|
||||
// 转换为base64
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$imageData = ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字体文件路径
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
private function getFontFile(): ?string
|
||||
{
|
||||
$fontPaths = [
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', // Linux
|
||||
'C:/Windows/Fonts/msyh.ttc', // Windows 微软雅黑
|
||||
'C:/Windows/Fonts/simhei.ttf', // Windows 黑体
|
||||
'/System/Library/Fonts/PingFang.ttc', // macOS
|
||||
];
|
||||
|
||||
foreach ($fontPaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\review;
|
||||
|
||||
use app\model\review\Review;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 审核字段映射服务
|
||||
*
|
||||
* 功能:
|
||||
* 1. 自动扫描并合并系统和所有插件的审核配置
|
||||
* 2. 根据审核类型动态映射字段(title/content/applicant)
|
||||
* 3. 支持多种字段映射方式(属性/关联/回调)
|
||||
* 4. 插件无需修改系统代码即可扩展审核类型
|
||||
*/
|
||||
class ReviewFieldMapper
|
||||
{
|
||||
/**
|
||||
* @var array 缓存的配置(系统 + 所有插件)
|
||||
*/
|
||||
protected static array $configCache = [];
|
||||
|
||||
/**
|
||||
* @var array 缓存的字段映射
|
||||
*/
|
||||
protected static array $fieldMappings = [];
|
||||
|
||||
/**
|
||||
* @var array 缓存的格式化函数
|
||||
*/
|
||||
protected static array $formatters = [];
|
||||
|
||||
/**
|
||||
* 初始化配置(懒加载)
|
||||
*/
|
||||
protected static function init(): void
|
||||
{
|
||||
if (!empty(self::$configCache)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 加载系统主配置
|
||||
$config = config('review', []);
|
||||
|
||||
// 2. 扫描并合并插件配置
|
||||
$pluginConfigs = self::scanPluginConfigs($config['scan_plugins'] ?? true);
|
||||
|
||||
// 3. 合并配置
|
||||
self::$configCache = self::mergeConfigs($config, $pluginConfigs);
|
||||
|
||||
// 4. 构建字段映射索引
|
||||
self::buildFieldMappings();
|
||||
|
||||
// 5. 初始化格式化函数
|
||||
self::initFormatters();
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描插件配置文件
|
||||
*/
|
||||
protected static function scanPluginConfigs(bool $enabled): array
|
||||
{
|
||||
$pluginConfigs = [];
|
||||
|
||||
if (!$enabled) {
|
||||
return $pluginConfigs;
|
||||
}
|
||||
|
||||
$ignoredPlugins = config('review.ignored_plugins', []);
|
||||
$pluginConfigFile = config('review.plugin_config_file', 'review.php');
|
||||
$pluginPath = base_path() . '/plugin';
|
||||
|
||||
if (!is_dir($pluginPath)) {
|
||||
return $pluginConfigs;
|
||||
}
|
||||
|
||||
$plugins = scandir($pluginPath);
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin === '.' || $plugin === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否在忽略列表中
|
||||
if (in_array($plugin, $ignoredPlugins, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configFile = "{$pluginPath}/{$plugin}/config/{$pluginConfigFile}";
|
||||
if (!is_file($configFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
if (is_array($config)) {
|
||||
// 为插件配置添加标识
|
||||
$config['_plugin'] = [
|
||||
'name' => $plugin,
|
||||
'display_name' => $config['plugin']['display_name'] ?? $plugin,
|
||||
];
|
||||
|
||||
// 按插件名存储,避免冲突
|
||||
$pluginConfigs[$plugin] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
return $pluginConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并系统配置和插件配置
|
||||
*/
|
||||
protected static function mergeConfigs(array $systemConfig, array $pluginConfigs): array
|
||||
{
|
||||
$merged = $systemConfig;
|
||||
|
||||
// 初始化合并结构
|
||||
if (!isset($merged['types'])) {
|
||||
$merged['types'] = [];
|
||||
}
|
||||
|
||||
if (!isset($merged['field_mappings'])) {
|
||||
$merged['field_mappings'] = [];
|
||||
}
|
||||
|
||||
if (!isset($merged['default_field_mappings'])) {
|
||||
$merged['default_field_mappings'] = [];
|
||||
}
|
||||
|
||||
// 合并插件配置
|
||||
foreach ($pluginConfigs as $pluginName => $pluginConfig) {
|
||||
// 合并审核类型
|
||||
if (isset($pluginConfig['types'])) {
|
||||
foreach ($pluginConfig['types'] as $typeKey => $typeConfig) {
|
||||
$merged['types'][$typeKey] = $typeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并字段映射
|
||||
if (isset($pluginConfig['field_mappings'])) {
|
||||
foreach ($pluginConfig['field_mappings'] as $typeKey => $mappings) {
|
||||
$merged['field_mappings'][$typeKey] = $mappings;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并格式化函数
|
||||
if (isset($pluginConfig['formatters'])) {
|
||||
if (!isset($merged['formatters'])) {
|
||||
$merged['formatters'] = [];
|
||||
}
|
||||
$merged['formatters'] = array_merge($merged['formatters'], $pluginConfig['formatters']);
|
||||
}
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段映射索引
|
||||
*/
|
||||
protected static function buildFieldMappings(): void
|
||||
{
|
||||
self::$fieldMappings = [];
|
||||
$config = self::$configCache;
|
||||
|
||||
// 1. 处理所有审核类型
|
||||
foreach ($config['field_mappings'] ?? [] as $typeKey => $mapping) {
|
||||
if (is_string($mapping) && class_exists($mapping)) {
|
||||
// 简单映射:类型 => 模型类
|
||||
self::$fieldMappings[$typeKey] = [
|
||||
'model' => $mapping,
|
||||
'fields' => [],
|
||||
];
|
||||
} elseif (is_array($mapping)) {
|
||||
// 详细映射配置
|
||||
self::$fieldMappings[$typeKey] = $mapping;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取 morph_map 映射(用于通过别名查找模型)
|
||||
$morphMap = config('morph_map.map', []);
|
||||
|
||||
// 3. 关联 morph_map 和 field_mappings
|
||||
foreach ($morphMap as $alias => $modelClass) {
|
||||
// 如果 field_mappings 中有此模型的配置,建立关联
|
||||
foreach (self::$fieldMappings as $typeKey => $mapping) {
|
||||
$mappingModel = $mapping['model'] ?? null;
|
||||
if ($mappingModel && $mappingModel === $modelClass) {
|
||||
// 建立别名到配置的映射
|
||||
self::$fieldMappings['_by_morph'][$alias] = $typeKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化格式化函数
|
||||
*/
|
||||
protected static function initFormatters(): void
|
||||
{
|
||||
$config = self::$configCache;
|
||||
self::$formatters = $config['formatters'] ?? [];
|
||||
|
||||
// 内置格式化函数
|
||||
$builtinFormatters = [
|
||||
'trim' => fn($value) => trim($value ?? ''),
|
||||
'html_to_text' => fn($html) => strip_tags($html ?? ''),
|
||||
'implode' => function($value, $glue = ',') {
|
||||
if (is_array($value)) {
|
||||
return implode($glue, $value);
|
||||
}
|
||||
return $value;
|
||||
},
|
||||
'datetime' => function($timestamp, $format = 'Y-m-d H:i:s') {
|
||||
if (is_numeric($timestamp)) {
|
||||
return date($format, (int)$timestamp);
|
||||
}
|
||||
return $timestamp;
|
||||
},
|
||||
'limit' => function($text, $length = 50) {
|
||||
return Str::limit($text, $length);
|
||||
},
|
||||
];
|
||||
|
||||
self::$formatters = array_merge($builtinFormatters, self::$formatters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字段值
|
||||
*/
|
||||
protected static function formatValue($value, ?array $fieldConfig): mixed
|
||||
{
|
||||
if ($value === null || empty($fieldConfig)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 应用格式化函数
|
||||
$format = $fieldConfig['format'] ?? null;
|
||||
if ($format) {
|
||||
// 处理带参数的格式化函数,如 "implode:、"
|
||||
if (is_string($format) && str_contains($format, ':')) {
|
||||
[$func, $param] = explode(':', $format, 2);
|
||||
$formatter = self::$formatters[$func] ?? null;
|
||||
if ($formatter) {
|
||||
return $formatter($value, $param);
|
||||
}
|
||||
} elseif (isset(self::$formatters[$format])) {
|
||||
return self::$formatters[$format]($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 morph_map 别名获取字段配置
|
||||
*/
|
||||
protected static function getFieldConfigByMorphAlias(string $morphAlias): ?array
|
||||
{
|
||||
$config = self::$fieldMappings['_by_morph'][$morphAlias] ?? null;
|
||||
if (!$config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$fieldMappings[$config] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据审核记录获取字段映射配置
|
||||
*/
|
||||
protected static function getFieldConfig(Review $review): ?array
|
||||
{
|
||||
$reviewableType = $review->reviewable_type;
|
||||
|
||||
// 1. 首先尝试通过 morph_map 别名查找
|
||||
$morphMap = config('morph_map.map', []);
|
||||
$morphAlias = array_search($reviewableType, $morphMap, true);
|
||||
|
||||
if ($morphAlias !== false) {
|
||||
$config = self::getFieldConfigByMorphAlias($morphAlias);
|
||||
if ($config) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 直接通过 reviewable_type(完整类名)查找
|
||||
return self::$fieldMappings[$reviewableType] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个字段的值
|
||||
*/
|
||||
protected static function getFieldValue($model, string $fieldName, array $fieldConfig): mixed
|
||||
{
|
||||
if (!$model || empty($fieldConfig)) {
|
||||
return $fieldConfig['fallback'] ?? null;
|
||||
}
|
||||
|
||||
$type = $fieldConfig['type'] ?? 'attribute';
|
||||
$source = $fieldConfig['source'] ?? $fieldName;
|
||||
|
||||
switch ($type) {
|
||||
case 'attribute':
|
||||
// 直接属性
|
||||
$value = $model->{$source} ?? null;
|
||||
break;
|
||||
|
||||
case 'relation':
|
||||
// 通过关联获取
|
||||
if (method_exists($model, $source)) {
|
||||
$relation = $model->{$source};
|
||||
if ($relation) {
|
||||
$attribute = $fieldConfig['attribute'] ?? 'name';
|
||||
$value = $relation->{$attribute} ?? null;
|
||||
} else {
|
||||
$value = null;
|
||||
}
|
||||
} else {
|
||||
$value = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'callback':
|
||||
// 回调函数
|
||||
$callback = $fieldConfig['callback'] ?? null;
|
||||
if (is_callable($callback)) {
|
||||
$value = $callback($model);
|
||||
} else {
|
||||
$value = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fixed':
|
||||
// 固定值
|
||||
$value = $fieldConfig['value'] ?? null;
|
||||
break;
|
||||
|
||||
default:
|
||||
$value = null;
|
||||
}
|
||||
|
||||
// 应用默认值
|
||||
if ($value === null || $value === '') {
|
||||
$value = $fieldConfig['fallback'] ?? null;
|
||||
}
|
||||
|
||||
// 格式化
|
||||
return self::formatValue($value, $fieldConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射审核记录字段
|
||||
*
|
||||
* @param Review $review 审核记录
|
||||
* @param array $extraFields 额外需要映射的字段
|
||||
* @return array 映射后的字段数组
|
||||
*/
|
||||
public static function mapReview(Review $review, array $extraFields = []): array
|
||||
{
|
||||
self::init();
|
||||
|
||||
$reviewable = $review->reviewable;
|
||||
$fieldConfig = self::getFieldConfig($review);
|
||||
|
||||
// 如果找不到配置,返回基本信息
|
||||
if (!$fieldConfig || !$reviewable) {
|
||||
return self::getFallbackFields($review);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
|
||||
// 1. 映射标准字段
|
||||
$standardFields = ['title', 'content', 'applicant'];
|
||||
foreach ($standardFields as $field) {
|
||||
$fieldConfigItem = $fieldConfig['fields'][$field] ?? self::$configCache['default_field_mappings'][$field] ?? null;
|
||||
$fields[$field] = self::getFieldValue($reviewable, $field, $fieldConfigItem);
|
||||
}
|
||||
|
||||
// 2. 映射额外字段
|
||||
foreach ($extraFields as $field) {
|
||||
if (isset($fieldConfig['fields'][$field])) {
|
||||
$fields[$field] = self::getFieldValue($reviewable, $field, $fieldConfig['fields'][$field]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 添加审核基础信息
|
||||
$fields = array_merge($fields, self::getBaseReviewFields($review, $fieldConfig));
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量映射审核记录
|
||||
*/
|
||||
public static function mapReviews(Collection $reviews, array $extraFields = []): Collection
|
||||
{
|
||||
return $reviews->map(function (Review $review) use ($extraFields) {
|
||||
$mapped = self::mapReview($review, $extraFields);
|
||||
|
||||
// 将映射字段合并到审核记录中
|
||||
foreach ($mapped as $key => $value) {
|
||||
if (!isset($review->{$key})) {
|
||||
$review->{$key} = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $review;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审核基础字段(无需配置)
|
||||
*/
|
||||
protected static function getBaseReviewFields(Review $review, ?array $fieldConfig = null): array
|
||||
{
|
||||
return [
|
||||
'id' => $review->id,
|
||||
'reviewable_type' => $review->reviewable_type,
|
||||
'reviewable_id' => $review->reviewable_id,
|
||||
'status' => $review->status,
|
||||
'status_text' => $review->status_text,
|
||||
'reason' => $review->reason,
|
||||
'reviewer_id' => $review->reviewer_id,
|
||||
'reviewer_name' => $review->reviewer?->real_name,
|
||||
'reviewed_at' => $review->reviewed_at,
|
||||
'created_at' => $review->created_at,
|
||||
'updated_at' => $review->updated_at,
|
||||
'display_name' => $fieldConfig['display_name'] ?? $review->reviewable_type,
|
||||
'morph_alias' => self::getMorphAlias($review->reviewable_type),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 fallback 字段(当找不到配置时)
|
||||
*/
|
||||
protected static function getFallbackFields(Review $review): array
|
||||
{
|
||||
$reviewable = $review->reviewable;
|
||||
|
||||
return array_merge(
|
||||
self::getBaseReviewFields($review),
|
||||
[
|
||||
'title' => $reviewable ? '已删除的数据' : '数据不存在',
|
||||
'content' => '',
|
||||
'applicant' => '未知',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过类名获取 morph_map 别名
|
||||
*/
|
||||
protected static function getMorphAlias(string $className): ?string
|
||||
{
|
||||
$morphMap = config('morph_map.map', []);
|
||||
return array_search($className, $morphMap, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已配置的审核类型
|
||||
*/
|
||||
public static function getConfiguredTypes(): array
|
||||
{
|
||||
self::init();
|
||||
|
||||
$types = [];
|
||||
foreach (self::$fieldMappings as $key => $config) {
|
||||
if ($key === '_by_morph') continue;
|
||||
|
||||
$types[$key] = [
|
||||
'display_name' => $config['display_name'] ?? $key,
|
||||
'model' => $config['model'] ?? null,
|
||||
'morph_alias' => self::getMorphAlias($config['model'] ?? ''),
|
||||
'priority' => $config['priority'] ?? 0,
|
||||
'icon' => $config['extra']['icon'] ?? null,
|
||||
'color' => $config['extra']['color'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// 按优先级排序
|
||||
uasort($types, fn($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定类型的字段配置
|
||||
*/
|
||||
public static function getTypeConfig(string $typeKey): ?array
|
||||
{
|
||||
self::init();
|
||||
return self::$fieldMappings[$typeKey] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\review;
|
||||
|
||||
use app\adminapi\CurrentUser;
|
||||
use app\dao\review\ReviewDao;
|
||||
use app\enum\review\ReviewStatus;
|
||||
use app\adminapi\event\ReviewApprovedEvent;
|
||||
use app\adminapi\event\ReviewRejectedEvent;
|
||||
use app\model\review\Review;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use Illuminate\Support\Collection;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 审核服务类
|
||||
* 支持两种审核模式:
|
||||
* 1. 简单审核模式:直接审核通过/拒绝
|
||||
* 2. 审批流模式:关联第三方审批流实例,由审批流模块处理
|
||||
*/
|
||||
class ReviewService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(ReviewDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建审核记录
|
||||
*
|
||||
* @param string $reviewableType 模型类型
|
||||
* @param int|string $reviewableId 模型ID
|
||||
* @param array $options 额外参数
|
||||
* - reason: string 审核原因
|
||||
* - flow_instance_id: string|null 审批流实例ID(传入则使用审批流模式)
|
||||
* - extra_data: array 扩展数据
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function createReview(string $reviewableType, int|string $reviewableId, array $options = []): ?\Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
try {
|
||||
// 检查是否已存在审核记录(无论状态,避免重复创建)
|
||||
$exists = $this->dao->getByReviewable($reviewableType, $reviewableId);
|
||||
if ($exists) {
|
||||
throw new AdminException('该记录已有审核记录');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'reviewable_type' => $reviewableType,
|
||||
'reviewable_id' => $reviewableId,
|
||||
'status' => ReviewStatus::PENDING->value,
|
||||
];
|
||||
|
||||
// 审核原因
|
||||
if (!empty($options['reason'])) {
|
||||
$data['reason'] = $options['reason'];
|
||||
}
|
||||
|
||||
// 审批流实例ID(审批流模式)
|
||||
if (!empty($options['flow_instance_id'])) {
|
||||
$data['flow_instance_id'] = $options['flow_instance_id'];
|
||||
}
|
||||
|
||||
// 扩展数据
|
||||
if (!empty($options['extra_data'])) {
|
||||
$data['extra_data'] = $options['extra_data'];
|
||||
}
|
||||
|
||||
return $this->dao->save($data);
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException('创建审核记录失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过
|
||||
*
|
||||
* @param int|string $id 审核记录ID
|
||||
* @param int|null $reviewerId 审核人ID(默认当前用户)
|
||||
* @param array $options 额外参数
|
||||
* - reason: string 审核原因/备注
|
||||
* - flow_callback: bool 是否为审批流回调(内部使用)
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function approve(int|string $id, ?int $reviewerId = null, array $options = []): bool
|
||||
{
|
||||
return $this->transaction(function () use ($id, $reviewerId, $options) {
|
||||
return $this->approveWithoutTransaction($id, $reviewerId, $options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过(不带事务)
|
||||
* 用于事件监听器等已经在事务中的场景
|
||||
*
|
||||
* @param int|string $id 审核记录ID
|
||||
* @param int|null $reviewerId 审核人ID(默认当前用户)
|
||||
* @param array $options 额外参数
|
||||
* - reason: string 审核原因/备注
|
||||
* - flow_callback: bool 是否为审批流回调(内部使用)
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function approveWithoutTransaction(int|string $id, ?int $reviewerId = null, array $options = []): bool
|
||||
{
|
||||
$review = $this->dao->get($id);
|
||||
if (empty($review)) {
|
||||
throw new AdminException('审核记录不存在');
|
||||
}
|
||||
if ($review->status !== ReviewStatus::PENDING->value) {
|
||||
throw new AdminException('该记录已审核');
|
||||
}
|
||||
|
||||
// 判断审核模式
|
||||
if ($review->isWorkflowMode() && empty($options['flow_callback'])) {
|
||||
// 审批流模式:调用第三方审批流模块
|
||||
return $this->handleWorkflowApprove($review, $reviewerId, $options);
|
||||
}
|
||||
|
||||
// 简单审核模式:直接审核通过
|
||||
$reviewerId = $reviewerId ?? config('review.auto_review.reviewer_id', 0);
|
||||
$review->status = ReviewStatus::APPROVED->value;
|
||||
$review->reviewer_id = $reviewerId;
|
||||
$review->reviewed_at = time();
|
||||
|
||||
if (!empty($options['reason'])) {
|
||||
$review->reason = $options['reason'];
|
||||
}
|
||||
|
||||
$review->save();
|
||||
|
||||
// 触发审核通过事件
|
||||
$event = new ReviewApprovedEvent($review);
|
||||
$event->dispatch();
|
||||
|
||||
// 执行审核通过回调(配置中的回调)
|
||||
$this->executeApprovedCallbacks($review);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行审核通过回调
|
||||
*
|
||||
* @param \app\model\review\Review $review
|
||||
* @return void
|
||||
*/
|
||||
protected function executeApprovedCallbacks($review): void
|
||||
{
|
||||
try {
|
||||
// 通过反射获取 ReviewFieldMapper 的 configCache(包含完整插件配置)
|
||||
$typeKey = $this->getReviewTypeKey($review);
|
||||
$typeConfig = $this->getReviewTypeConfigFromMapper($typeKey);
|
||||
|
||||
if (empty($typeConfig['callbacks']['approved'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$callbacks = $typeConfig['callbacks']['approved'];
|
||||
|
||||
// 支持单个类名或类名数组
|
||||
if (is_string($callbacks)) {
|
||||
$callbacks = [$callbacks];
|
||||
}
|
||||
|
||||
foreach ($callbacks as $class) {
|
||||
if (!class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$instance = new $class();
|
||||
|
||||
if (method_exists($instance, 'handle')) {
|
||||
$instance->handle($review);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 回调执行失败不影响审核流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审核类型 key
|
||||
*/
|
||||
protected function getReviewTypeKey($review): string
|
||||
{
|
||||
// 尝试通过 morph_map 查找别名
|
||||
$morphMap = config('morph_map.map', []);
|
||||
$alias = array_search($review->reviewable_type, $morphMap, true);
|
||||
|
||||
return $alias !== false ? $alias : $review->reviewable_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ReviewFieldMapper 获取完整类型配置
|
||||
*/
|
||||
protected function getReviewTypeConfigFromMapper(string $typeKey): ?array
|
||||
{
|
||||
try {
|
||||
// 先调用 getTypeConfig 确保 init() 被执行,configCache 被填充
|
||||
ReviewFieldMapper::getTypeConfig($typeKey);
|
||||
|
||||
// 使用反射获取 configCache
|
||||
$reflection = new \ReflectionClass(ReviewFieldMapper::class);
|
||||
$property = $reflection->getProperty('configCache');
|
||||
$property->setAccessible(true);
|
||||
$configCache = $property->getValue();
|
||||
|
||||
// configCache 结构: ['types' => [...], 'field_mappings' => [...]]
|
||||
return $configCache['types'][$typeKey] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核拒绝
|
||||
*
|
||||
* @param int|string $id 审核记录ID
|
||||
* @param string $reason 拒绝原因
|
||||
* @param int|null $reviewerId 审核人ID(默认当前用户)
|
||||
* @param array $options 额外参数
|
||||
* - flow_callback: bool 是否为审批流回调(内部使用)
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function reject(int|string $id, string $reason = '', ?int $reviewerId = null, array $options = []): bool
|
||||
{
|
||||
return $this->transaction(function () use ($id, $reason, $reviewerId, $options) {
|
||||
return $this->rejectWithoutTransaction($id, $reason, $reviewerId, $options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核拒绝(不带事务)
|
||||
* 用于事件监听器等已经在事务中的场景
|
||||
*
|
||||
* @param int|string $id 审核记录ID
|
||||
* @param string $reason 拒绝原因
|
||||
* @param int|null $reviewerId 审核人ID(默认当前用户)
|
||||
* @param array $options 额外参数
|
||||
* - flow_callback: bool 是否为审批流回调(内部使用)
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function rejectWithoutTransaction(int|string $id, string $reason = '', ?int $reviewerId = null, array $options = []): bool
|
||||
{
|
||||
$review = $this->dao->get($id);
|
||||
if (empty($review)) {
|
||||
throw new AdminException('审核记录不存在');
|
||||
}
|
||||
if ($review->status !== ReviewStatus::PENDING->value) {
|
||||
throw new AdminException('该记录已审核');
|
||||
}
|
||||
|
||||
// 判断审核模式
|
||||
if ($review->isWorkflowMode() && empty($options['flow_callback'])) {
|
||||
// 审批流模式:调用第三方审批流模块
|
||||
return $this->handleWorkflowReject($review, $reason, $reviewerId, $options);
|
||||
}
|
||||
|
||||
// 简单审核模式:直接审核拒绝
|
||||
$reviewerId = $reviewerId ?? config('review.auto_review.reviewer_id', 0);
|
||||
$review->status = ReviewStatus::REJECTED->value;
|
||||
$review->reviewer_id = $reviewerId;
|
||||
$review->reviewed_at = time();
|
||||
if (!empty($reason)) {
|
||||
$review->reason = $reason;
|
||||
}
|
||||
$review->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量审核通过
|
||||
*
|
||||
* @param array $ids 审核记录ID数组
|
||||
* @param array $options 额外参数
|
||||
*
|
||||
* @return int 审核通过的数量
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function batchApprove(array $ids, array $options = []): int
|
||||
{
|
||||
return $this->transaction(function () use ($ids, $options) {
|
||||
// 直接使用 whereIn 查询待审核的记录,避免 selectList 的条件处理问题
|
||||
$pendingReviews = $this->dao->getModel()
|
||||
->whereIn('id', $ids)
|
||||
->where('status', ReviewStatus::PENDING->value)
|
||||
->get(['id']);
|
||||
|
||||
$pendingIds = $pendingReviews->pluck('id')->toArray();
|
||||
|
||||
// 调试日志
|
||||
\support\Log::info('batchApprove', [
|
||||
'input_ids' => $ids,
|
||||
'pending_ids' => $pendingIds,
|
||||
'options' => $options
|
||||
]);
|
||||
|
||||
if (empty($pendingIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$reviewerId = Container::make(CurrentUser::class)->id();
|
||||
$reviewerId = $reviewerId ? (int)$reviewerId : null;
|
||||
$count = 0;
|
||||
|
||||
// 批量更新待审核的记录
|
||||
foreach ($pendingIds as $id) {
|
||||
try {
|
||||
$review = $this->dao->get($id);
|
||||
if (empty($review)) {
|
||||
\support\Log::warning("Review not found: {$id}");
|
||||
continue;
|
||||
}
|
||||
if ($review->status !== ReviewStatus::PENDING->value) {
|
||||
\support\Log::warning("Review not pending: {$id}, status: {$review->status}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 判断审核模式
|
||||
if ($review->isWorkflowMode() && empty($options['flow_callback'])) {
|
||||
// 审批流模式:调用第三方审批流模块
|
||||
$this->handleWorkflowApprove($review, $reviewerId, $options);
|
||||
} else {
|
||||
// 简单审核模式:直接审核通过
|
||||
$review->status = ReviewStatus::APPROVED->value;
|
||||
$review->reviewer_id = $reviewerId;
|
||||
$review->reviewed_at = time();
|
||||
|
||||
if (!empty($options['reason'])) {
|
||||
$review->reason = $options['reason'];
|
||||
}
|
||||
|
||||
$review->save();
|
||||
|
||||
$event = new ReviewApprovedEvent($review);
|
||||
$event->dispatch();
|
||||
}
|
||||
|
||||
$count++;
|
||||
\support\Log::info("Approved review: {$id}, count: {$count}");
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但继续处理其他记录
|
||||
\support\Log::error("Failed to approve review: {$id}", [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
\support\Log::info('batchApprove completed', ['total_count' => $count]);
|
||||
return $count;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量审核拒绝
|
||||
*
|
||||
* @param array $ids 审核记录ID数组
|
||||
* @param string $reason 拒绝原因
|
||||
* @param array $options 额外参数
|
||||
*
|
||||
* @return int 审核拒绝的数量
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function batchReject(array $ids, string $reason = '', array $options = []): int
|
||||
{
|
||||
return $this->transaction(function () use ($ids, $reason, $options) {
|
||||
// 直接使用 whereIn 查询待审核的记录,避免 selectList 的条件处理问题
|
||||
$pendingReviews = $this->dao->getModel()
|
||||
->whereIn('id', $ids)
|
||||
->where('status', ReviewStatus::PENDING->value)
|
||||
->get(['id']);
|
||||
|
||||
$pendingIds = $pendingReviews->pluck('id')->toArray();
|
||||
|
||||
// 调试日志
|
||||
\support\Log::info('batchReject', [
|
||||
'input_ids' => $ids,
|
||||
'pending_ids' => $pendingIds,
|
||||
'reason' => $reason,
|
||||
'options' => $options
|
||||
]);
|
||||
|
||||
if (empty($pendingIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$reviewerId = Container::make(CurrentUser::class)->id();
|
||||
$reviewerId = $reviewerId ? (int)$reviewerId : null;
|
||||
$count = 0;
|
||||
|
||||
// 批量更新待审核的记录
|
||||
foreach ($pendingIds as $id) {
|
||||
try {
|
||||
$review = $this->dao->get($id);
|
||||
if (empty($review)) {
|
||||
\support\Log::warning("Review not found: {$id}");
|
||||
continue;
|
||||
}
|
||||
if ($review->status !== ReviewStatus::PENDING->value) {
|
||||
\support\Log::warning("Review not pending: {$id}, status: {$review->status}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 判断审核模式
|
||||
if ($review->isWorkflowMode() && empty($options['flow_callback'])) {
|
||||
// 审批流模式:调用第三方审批流模块
|
||||
$this->handleWorkflowReject($review, $reason, $reviewerId, $options);
|
||||
} else {
|
||||
// 简单审核模式:直接审核拒绝
|
||||
$review->status = ReviewStatus::REJECTED->value;
|
||||
$review->reviewer_id = $reviewerId;
|
||||
$review->reviewed_at = time();
|
||||
if (!empty($reason)) {
|
||||
$review->reason = $reason;
|
||||
}
|
||||
$review->save();
|
||||
|
||||
$event = new ReviewRejectedEvent($review);
|
||||
$event->dispatch();
|
||||
}
|
||||
|
||||
$count++;
|
||||
\support\Log::info("Rejected review: {$id}, count: {$count}");
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但继续处理其他记录
|
||||
\support\Log::error("Failed to reject review: {$id}", [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
\support\Log::info('batchReject completed', ['total_count' => $count]);
|
||||
return $count;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批流回调 - 审核通过
|
||||
* 由第三方审批流模块调用,更新审核状态
|
||||
*
|
||||
* @param string $flowInstanceId 审批流实例ID
|
||||
* @param int|null $reviewerId 审核人ID
|
||||
* @param string $reason 审核原因
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function flowApproveCallback(string $flowInstanceId, ?int $reviewerId = null, string $reason = ''): bool
|
||||
{
|
||||
$review = $this->dao->getOne(['flow_instance_id' => $flowInstanceId]);
|
||||
if (empty($review)) {
|
||||
throw new AdminException('审核记录不存在');
|
||||
}
|
||||
|
||||
return $this->approve($review->id, $reviewerId, [
|
||||
'reason' => $reason,
|
||||
'flow_callback' => true, // 标记为审批流回调
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批流回调 - 审核拒绝
|
||||
* 由第三方审批流模块调用,更新审核状态
|
||||
*
|
||||
* @param string $flowInstanceId 审批流实例ID
|
||||
* @param string $reason 拒绝原因
|
||||
* @param int|null $reviewerId 审核人ID
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function flowRejectCallback(string $flowInstanceId, string $reason = '', ?int $reviewerId = null): bool
|
||||
{
|
||||
$review = $this->dao->getOne(['flow_instance_id' => $flowInstanceId]);
|
||||
if (empty($review)) {
|
||||
throw new AdminException('审核记录不存在');
|
||||
}
|
||||
|
||||
return $this->reject($review->id, $reason, $reviewerId, [
|
||||
'flow_callback' => true, // 标记为审批流回调
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审核统计
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$total = $this->dao->count();
|
||||
$pending = $this->dao->getPendingCount();
|
||||
$approved = $this->dao->count(['status' => ReviewStatus::APPROVED->value]);
|
||||
$rejected = $this->dao->count(['status' => ReviewStatus::REJECTED->value]);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'pending' => $pending,
|
||||
'approved' => $approved,
|
||||
'rejected' => $rejected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据关联对象获取审核记录
|
||||
*
|
||||
* @param string $reviewableType 模型类型
|
||||
* @param int|string $reviewableId 模型ID
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getByReviewable(string $reviewableType, int|string $reviewableId): ?\Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return $this->dao->getByReviewable($reviewableType, $reviewableId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据审批流实例ID获取审核记录
|
||||
*
|
||||
* @param string $flowInstanceId 审批流实例ID
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getByFlowInstanceId(string $flowInstanceId): ?\Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return $this->dao->getOne(['flow_instance_id' => $flowInstanceId]);
|
||||
}
|
||||
|
||||
// ==================== 审批流模式处理 ====================
|
||||
|
||||
/**
|
||||
* 处理审批流模式 - 审核通过
|
||||
* 调用第三方审批流模块的审核通过接口
|
||||
*
|
||||
* @param Review $review 审核记录
|
||||
* @param int|null $reviewerId 审核人ID
|
||||
* @param array $options 额外参数
|
||||
*
|
||||
* @return bool
|
||||
* @throws AdminException
|
||||
*/
|
||||
protected function handleWorkflowApprove(Review $review, ?int $reviewerId, array $options): bool
|
||||
{
|
||||
// 调用第三方审批流模块的审核通过接口
|
||||
// 示例:
|
||||
// $flowService = app(config('review.flow_service'));
|
||||
// return $flowService->approve($review->flow_instance_id, $reviewerId, $options);
|
||||
|
||||
// 未配置第三方审批流模块时,抛出异常
|
||||
throw new AdminException('审批流模块未配置,请联系管理员');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理审批流模式 - 审核拒绝
|
||||
* 调用第三方审批流模块的审核拒绝接口
|
||||
*
|
||||
* @param Review $review 审核记录
|
||||
* @param string $reason 拒绝原因
|
||||
* @param int|null $reviewerId 审核人ID
|
||||
* @param array $options 额外参数
|
||||
*
|
||||
* @return bool
|
||||
* @throws AdminException
|
||||
*/
|
||||
protected function handleWorkflowReject(Review $review, string $reason, ?int $reviewerId, array $options): bool
|
||||
{
|
||||
// 调用第三方审批流模块的审核拒绝接口
|
||||
// 示例:
|
||||
// $flowService = app(config('review.flow_service'));
|
||||
// return $flowService->reject($review->flow_instance_id, $reason, $reviewerId, $options);
|
||||
|
||||
// 未配置第三方审批流模块时,抛出异常
|
||||
throw new AdminException('审批流模块未配置,请联系管理员');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取映射后的审核列表(支持插件配置)
|
||||
*
|
||||
* @param array $where 查询条件
|
||||
* @param string $field 查询字段
|
||||
* @param int $page 页码
|
||||
* @param int $limit 每页数量
|
||||
* @param string $order 排序
|
||||
* @param array $with 关联预加载
|
||||
* @param bool $search 是否搜索模式
|
||||
*
|
||||
* @return Collection
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getMappedList(array $where = [], string $field = '*', int $page = 0, int $limit = 0, string $order = '', array $with = [], bool $search = false): Collection
|
||||
{
|
||||
$reviews = $this->dao->selectList($where, $field, $page, $limit, $order, $with, $search);
|
||||
return ReviewFieldMapper::mapReviews($reviews);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条映射后的审核记录
|
||||
*
|
||||
* @param int|string $id 审核记录ID
|
||||
* @param array $extraFields 额外需要映射的字段
|
||||
*
|
||||
* @return array|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getMappedReview(int|string $id, array $extraFields = []): ?array
|
||||
{
|
||||
$review = $this->dao->get($id,['*'], ['reviewer', 'reviewable']);
|
||||
if (!$review) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReviewFieldMapper::mapReview($review, $extraFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已配置的审核类型(用于前端下拉选择)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getReviewTypes(): array
|
||||
{
|
||||
return ReviewFieldMapper::getConfiguredTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定审核类型的配置信息
|
||||
*
|
||||
* @param string $typeKey 类型键(morph_map别名)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function getReviewTypeConfig(string $typeKey): ?array
|
||||
{
|
||||
return ReviewFieldMapper::getTypeConfig($typeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建审核记录(增强版,自动填充extra_data)
|
||||
* 简化版:直接调用基础方法,extra_data由调用方提供
|
||||
*
|
||||
* @param string $reviewableType 模型类型(可以是类名或morph_map别名)
|
||||
* @param int|string $reviewableId 模型ID
|
||||
* @param array $options 额外参数
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function createReviewWithData(string $reviewableType, int|string $reviewableId, array $options = []): ?\Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return $this->createReview($reviewableType, $reviewableId, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据morph_map别名获取模型类名
|
||||
*/
|
||||
protected function getModelClassFromMorphAlias(string $alias): ?string
|
||||
{
|
||||
$morphMap = config('morph_map.map', []);
|
||||
return $morphMap[$alias] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置构建extra_data
|
||||
*/
|
||||
protected function buildExtraDataFromConfig($reviewable, array $fieldConfigs): array
|
||||
{
|
||||
$extraData = [];
|
||||
|
||||
foreach ($fieldConfigs as $fieldName => $config) {
|
||||
if (in_array($fieldName, ['title', 'content', 'applicant'])) {
|
||||
// 获取字段值
|
||||
$value = $this->getFieldValueFromConfig($reviewable, $fieldName, $config);
|
||||
$extraData[$fieldName] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $extraData;
|
||||
}
|
||||
|
||||
// ==================== 搜索处理相关方法 ====================
|
||||
|
||||
/**
|
||||
* 获取数量(支持 extra_data 字段搜索)
|
||||
*
|
||||
* @param array $where
|
||||
* @param bool $search
|
||||
*
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getCountWithSearch(array $where = [], bool $search = false): int
|
||||
{
|
||||
return $this->dao->count($where, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置获取字段值
|
||||
*
|
||||
* @param mixed $model 模型对象
|
||||
* @param string $fieldName 字段名
|
||||
* @param array $fieldConfig 字段配置
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getFieldValueFromConfig($model, string $fieldName, array $fieldConfig): mixed
|
||||
{
|
||||
if (!$model || empty($fieldConfig)) {
|
||||
return $fieldConfig['fallback'] ?? null;
|
||||
}
|
||||
|
||||
$type = $fieldConfig['type'] ?? 'attribute';
|
||||
$source = $fieldConfig['source'] ?? $fieldName;
|
||||
|
||||
switch ($type) {
|
||||
case 'attribute':
|
||||
// 直接获取属性
|
||||
return $model->{$source} ?? ($fieldConfig['fallback'] ?? null);
|
||||
|
||||
case 'relation':
|
||||
// 通过关联获取
|
||||
if ($model->{$source} && isset($fieldConfig['attribute'])) {
|
||||
return $model->{$source}->{$fieldConfig['attribute']} ?? ($fieldConfig['fallback'] ?? null);
|
||||
}
|
||||
return $fieldConfig['fallback'] ?? null;
|
||||
|
||||
case 'callback':
|
||||
// 回调函数
|
||||
if (isset($fieldConfig['callback']) && is_callable($fieldConfig['callback'])) {
|
||||
return call_user_func($fieldConfig['callback'], $model);
|
||||
}
|
||||
return $fieldConfig['fallback'] ?? null;
|
||||
|
||||
case 'fixed':
|
||||
// 固定值
|
||||
return $fieldConfig['value'] ?? ($fieldConfig['fallback'] ?? null);
|
||||
|
||||
default:
|
||||
return $fieldConfig['fallback'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\AdminRoleDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
* @method query()
|
||||
*/
|
||||
class AdminRoleService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(AdminRoleDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户角色关联
|
||||
*
|
||||
* @param array $data 包含user_id和role_id_list的数组
|
||||
*
|
||||
* @throws AdminException
|
||||
*/
|
||||
public function save(array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$userId = $data['id'] ?? 0;
|
||||
$newRoleIds = $data['role_id_list'] ?? [];
|
||||
|
||||
if ($userId <= 0) {
|
||||
throw new AdminException("用户ID不能为空");
|
||||
}
|
||||
|
||||
// 1. 获取用户现有角色ID
|
||||
$oldRoleIds = array_column(
|
||||
$this->query()->where('admin_id', $userId)->get()->toArray(),
|
||||
'role_id'
|
||||
);
|
||||
|
||||
// 2. 计算需要删除和新增的角色ID(差集计算)
|
||||
$rolesToDelete = array_diff($oldRoleIds, $newRoleIds);
|
||||
$rolesToAdd = array_diff($newRoleIds, $oldRoleIds);
|
||||
|
||||
// 3. 批量清理旧角色关联
|
||||
if (!empty($rolesToDelete)) {
|
||||
// 中间表批量删除
|
||||
$this->dao->getModel()
|
||||
->where('admin_id', $userId)
|
||||
->whereIn('role_id', $rolesToDelete)
|
||||
->delete();
|
||||
}
|
||||
|
||||
// 4. 批量添加新角色关联
|
||||
if (!empty($rolesToAdd)) {
|
||||
// 中间表批量插入
|
||||
$insertData = array_map(function ($roleId) use ($userId) {
|
||||
return ['admin_id' => $userId, 'role_id' => $roleId];
|
||||
}, $rolesToAdd);
|
||||
$this->dao->getModel()->insert($insertData);
|
||||
}
|
||||
|
||||
// 5. 清理用户权限缓存
|
||||
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
|
||||
$currentUser->clearCache($userId);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException("角色授权失败:{$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户-关联角色
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function removeUserRole(array $data)
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$userIds = [];
|
||||
foreach ($data as $item) {
|
||||
$this->dao->getModel()->where($item)->delete();
|
||||
$userIds[] = $item['admin_id'];
|
||||
}
|
||||
|
||||
// 清理相关用户的权限缓存
|
||||
$userIds = array_unique($userIds);
|
||||
if (!empty($userIds)) {
|
||||
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
|
||||
foreach ($userIds as $userId) {
|
||||
$currentUser->clearCache($userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户-关联角色
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function saveUserRoles(array $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$this->dao->saveAll($data);
|
||||
|
||||
// 清理相关用户的权限缓存
|
||||
$userIds = array_unique(array_column($data, 'admin_id'));
|
||||
if (!empty($userIds)) {
|
||||
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
|
||||
foreach ($userIds as $userId) {
|
||||
$currentUser->clearCache($userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过角色ID获取用户ID列表
|
||||
*
|
||||
* @param int|string $roleId
|
||||
* @return array
|
||||
*/
|
||||
public function getUserIdsByRoleId(int|string $roleId): array
|
||||
{
|
||||
return $this->dao->getModel()
|
||||
->where('role_id', $roleId)
|
||||
->pluck('admin_id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\adminapi\event\LoginLogEvent;
|
||||
use app\dao\system\AdminDao;
|
||||
use app\enum\system\PolicyPrefix;
|
||||
use app\model\system\Admin;
|
||||
use app\scope\global\AccessPermissionScope;
|
||||
use app\service\admin\logs\LoginLogService;
|
||||
use core\base\BaseService;
|
||||
use core\cache\CacheService;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\jwt\JwtToken;
|
||||
use core\tool\RSAService;
|
||||
use support\Container;
|
||||
use Webman\Event\Event;
|
||||
|
||||
/**
|
||||
* @method getAdminInfo(string $username)
|
||||
* @method getAdminById($uid, $withoutScopes = null)
|
||||
* @method getList(mixed $where, mixed $field, mixed $page, mixed $limit, mixed $order, array $array, false $false)
|
||||
* @method getUsersListByRoleId(mixed $where, mixed $field, mixed $page, mixed $limit)
|
||||
* @method getAdminByName(string $username, array|null $withoutScopes)
|
||||
*/
|
||||
class AdminService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(AdminDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* save
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return Admin|null
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function save(array $data): Admin|null
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($data) {
|
||||
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
$roles = $this->normalizeRelationIds($data['role_id_list'] ?? []);
|
||||
$posts = $this->normalizeRelationIds($data['post_id_list'] ?? []);
|
||||
$depts = $this->normalizeRelationIds($data['dept_id_list'] ?? []);
|
||||
$mainDeptId = $data['main_dept_id'] ?? null;
|
||||
$mainPosId = $data['main_post_id'] ?? null;
|
||||
unset($data['role_id_list'], $data['post_id_list'], $data['dept_id_list'], $data['main_dept_id'], $data['main_post_id']);
|
||||
$model = $this->dao->save($data);
|
||||
|
||||
$this->updateModel($model, $data, $depts, $posts, $roles);
|
||||
$this->syncRoles($model, $roles);
|
||||
$this->syncMainInfo($model, $mainDeptId, $mainPosId);
|
||||
return $model;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
* @param int|string $id
|
||||
* @param array $data
|
||||
*
|
||||
* @return \app\model\system\Admin|null
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update(int|string $id, array $data): ?Admin
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($id, $data) {
|
||||
$this->updatePasswordIfNeeded($data);
|
||||
$roles = $this->normalizeRelationIds($data['role_id_list'] ?? []);
|
||||
$posts = $this->normalizeRelationIds($data['post_id_list'] ?? []);
|
||||
$depts = $this->normalizeRelationIds($data['dept_id_list'] ?? []);
|
||||
$mainDeptId = $data['main_dept_id'] ?? null;
|
||||
$mainPosId = $data['main_post_id'] ?? null;
|
||||
unset($data['role_id_list'], $data['post_id_list'], $data['dept_id_list'], $data['main_dept_id'], $data['main_post_id']);
|
||||
$model = $this->dao->getModel()
|
||||
->findOrFail($id);
|
||||
$this->updateModel($model, $data, $depts, $posts, $roles);
|
||||
$this->syncRoles($model, $roles);
|
||||
$this->syncMainInfo($model, $mainDeptId, $mainPosId);
|
||||
return $model;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRelationIds(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values(array_filter($value, static fn ($item) => $item !== null && $item !== ''));
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return array_values(array_filter(array_map('trim', explode(',', $value)), static fn ($item) => $item !== ''));
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$value];
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码处理
|
||||
*
|
||||
* @param array $data
|
||||
*/
|
||||
private function updatePasswordIfNeeded(array &$data): void
|
||||
{
|
||||
if (isset($data['password'])) {
|
||||
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新-部门职位关系
|
||||
*
|
||||
* @param \app\model\system\Admin $model
|
||||
* @param array $data
|
||||
* @param array $depts
|
||||
* @param array $posts
|
||||
* @param array $roles
|
||||
*/
|
||||
private function updateModel(Admin $model, array $data, array $depts, array $posts, array $roles): void
|
||||
{
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
$model->depts()->sync($depts);
|
||||
$model->posts()->sync($posts);
|
||||
$model->roles()->sync($roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步-角色
|
||||
*
|
||||
* @param \app\model\system\Admin $model
|
||||
* @param array $roles
|
||||
*/
|
||||
private function syncRoles(Admin $model, array $roles): void
|
||||
{
|
||||
$model->roles()->sync($roles);
|
||||
}
|
||||
|
||||
private function syncMainInfo(Admin $model, ?string $mainDeptId, ?string $mainPosId): void
|
||||
{
|
||||
$mainInfo = \app\model\system\AdminMain::updateOrCreate(
|
||||
['admin_id' => (string)$model->id],
|
||||
[
|
||||
'main_dept_id' => $mainDeptId,
|
||||
'main_post_id' => $mainPosId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy
|
||||
*
|
||||
* @param $id
|
||||
* @param $force
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function destroy($id, $force): mixed
|
||||
{
|
||||
$ret = $this->dao->count([['id', 'in', $id], ['is_super', '=', 1]]);
|
||||
if ($ret > 0) {
|
||||
throw new AdminException('系统内置用户,不允许删除');
|
||||
}
|
||||
return $this->dao->destroy($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户-冻结
|
||||
*
|
||||
* @param array|string $id
|
||||
*/
|
||||
public function locked(array|string $id): void
|
||||
{
|
||||
try {
|
||||
if (is_string($id)) {
|
||||
$id = array_map('trim', explode(',', $id));
|
||||
}
|
||||
$ret = $this->dao->count([['id', 'in', $id], ['is_super', '=', 1]]);
|
||||
if ($ret > 0) {
|
||||
throw new AdminException('系统内置用户,不允许冻结');
|
||||
}
|
||||
$this->dao->batchUpdate($id, ['is_locked' => 1]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户-解除冻结
|
||||
*
|
||||
* @param array|string $id
|
||||
*/
|
||||
public function unLocked(array|string $id): void
|
||||
{
|
||||
try {
|
||||
if (is_string($id)) {
|
||||
$id = array_map('trim', explode(',', $id));
|
||||
}
|
||||
$this->dao->batchUpdate($id, ['is_locked' => 0]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $type
|
||||
* @param string $grantType
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function login(string $username, string $password = '', string $type = 'admin', string $grantType = 'default', array $params = []): array
|
||||
{
|
||||
$adminInfo = $this->getAdminByName($username, [AccessPermissionScope::class]);
|
||||
$this->validateAdminStatus($adminInfo);
|
||||
$decryptedPassword = $this->validateRsaKeys($params['key_id'], $password);
|
||||
// $decryptedPassword= $password;//临时注释关闭RSA验证要不无法调试
|
||||
$this->validatePassword($adminInfo, $decryptedPassword, $grantType);
|
||||
[$userInfo, $token] = $this->generateTokenData($adminInfo, $type);
|
||||
$this->emitLoginSuccessEvent(array_merge($userInfo, $token), $tenant?->id ?? null);
|
||||
return $token ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方应用登录
|
||||
*
|
||||
* @param mixed $acctId
|
||||
* @param mixed $appId
|
||||
* @param mixed $appSecret
|
||||
* @param mixed $userName
|
||||
* @param string $type
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function thirdPartyLogin(mixed $acctId, mixed $appId, mixed $appSecret, mixed $userName, string $type = 'admin'): array
|
||||
{
|
||||
$adminInfo = $this->getAdminByName($userName);
|
||||
$this->validateAdminStatus($adminInfo);
|
||||
$this->validateThirdPartyApp($acctId, $appId, $appSecret);
|
||||
[$userInfo, $token] = $this->generateTokenData($adminInfo, $type);
|
||||
$this->emitLoginSuccessEvent(array_merge($userInfo, $token), $tenant?->id ?? null);
|
||||
return $token ?? [];
|
||||
}
|
||||
|
||||
private function validateThirdPartyApp(mixed $acctId, mixed $appId, mixed $appSecret)
|
||||
{
|
||||
|
||||
if ($acctId !== config('app.acct_id')) {
|
||||
throw new AdminException('第三方应用不存在或已删除');
|
||||
}
|
||||
if ($appId !== config('app.app_id')) {
|
||||
throw new AdminException('第三方应用ID错误');
|
||||
}
|
||||
if ($appSecret !== config('app.app_secret')) {
|
||||
throw new AdminException('第三方应用ID或密钥错误');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证管理员状态
|
||||
*
|
||||
* @param \app\model\system\Admin|null $adminInfo
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function validateAdminStatus(?Admin $adminInfo): void
|
||||
{
|
||||
if (!$adminInfo) {
|
||||
throw new AdminException('账号或密码错误,请重新输入!');
|
||||
}
|
||||
if ($adminInfo->enabled === 0) {
|
||||
throw new AdminException('您已被禁止登录!');
|
||||
}
|
||||
if ($adminInfo->is_locked === 1) {
|
||||
throw new AdminException('您的账号已被锁定,禁止登录!');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
*
|
||||
* @param \app\model\system\Admin $adminInfo
|
||||
* @param string $password
|
||||
* @param string $grantType
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function validatePassword(Admin $adminInfo, string $password, string $grantType): void
|
||||
{
|
||||
if (!in_array($grantType, ['sms', 'refresh_token']) && !password_verify($password, $adminInfo->password)) {
|
||||
$msg = '账号或密码错误,请重新输入!';
|
||||
$this->emitLoginFailedEvent($adminInfo->toArray(), $msg);
|
||||
throw new AdminException($msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* token生成
|
||||
*
|
||||
* @param \app\model\system\Admin $adminInfo
|
||||
* @param string $type
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function generateTokenData(Admin $adminInfo, string $type): array
|
||||
{
|
||||
$loginIp = request()->getRealIp();
|
||||
$userAgent = request()->header('user-agent', '');
|
||||
$browser = $this->getBrowser($userAgent);
|
||||
$os = $this->getOs($userAgent);
|
||||
$ipLocation = $this->getIpLocation($loginIp);
|
||||
|
||||
$adminInfo->login_time = time();
|
||||
$adminInfo->login_ip = $loginIp;
|
||||
$adminInfo->save();
|
||||
$userInfo = $adminInfo->makeHidden([
|
||||
'password',
|
||||
'backend_setting',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'deleted_at',
|
||||
'remark',
|
||||
'created_date',
|
||||
'updated_date',
|
||||
])->toArray();
|
||||
|
||||
// 追加扩展数据到 userInfo(用于 JWT extra)
|
||||
$userInfo['ip'] = $loginIp;
|
||||
$userInfo['ip_location'] = $ipLocation;
|
||||
$userInfo['browser'] = $browser;
|
||||
$userInfo['os'] = $os;
|
||||
|
||||
// 使用新的 JwtToken 生成 token
|
||||
$jwt = new JwtToken();
|
||||
$tokenObj = $jwt->generate((string)$adminInfo->id, $type, $userInfo);
|
||||
|
||||
$token = [
|
||||
'access_token' => $tokenObj->accessToken,
|
||||
'refresh_token' => $tokenObj->refreshToken,
|
||||
'expires_in' => $tokenObj->expiresIn,
|
||||
'client_id' => $this->generateUniqueId($loginIp),
|
||||
'expires_time' => time() + $tokenObj->expiresIn
|
||||
];
|
||||
|
||||
return [$userInfo, $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功-事件发起
|
||||
*
|
||||
* @param array $tokenData
|
||||
* @param int|null $tenantId
|
||||
*/
|
||||
private function emitLoginSuccessEvent(array $tokenData, ?int $tenantId = null): void
|
||||
{
|
||||
$loginIp = request()->getRealIp();
|
||||
$event = new LoginLogEvent(
|
||||
'登录成功',
|
||||
request()->app,
|
||||
$loginIp,
|
||||
$this->getIpLocation($loginIp),
|
||||
$this->getBrowser(request()->header('user-agent', '')),
|
||||
$this->getOs(request()->header('user-agent', '')),
|
||||
0,
|
||||
'登录成功',
|
||||
$tokenData['user_name'],
|
||||
$tokenData['id'],
|
||||
time(),
|
||||
$tokenData['access_token'],
|
||||
$tokenData['expires_time']
|
||||
);
|
||||
$event->dispatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录失败-事件发起
|
||||
*
|
||||
* @param array $adminInfo
|
||||
* @param string $message
|
||||
*/
|
||||
private function emitLoginFailedEvent(array $adminInfo, string $message): void
|
||||
{
|
||||
$loginIp = request()->getRealIp();
|
||||
$event = new LoginLogEvent(
|
||||
'登录失败',
|
||||
request()->app,
|
||||
$loginIp,
|
||||
$this->getIpLocation($loginIp),
|
||||
$this->getBrowser(request()->header('user-agent', '')),
|
||||
$this->getOs(request()->header('user-agent', '')),
|
||||
-1,
|
||||
$message,
|
||||
$adminInfo['user_name'],
|
||||
$adminInfo['id'],
|
||||
time(),
|
||||
'',
|
||||
time()
|
||||
);
|
||||
$event->dispatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器信息
|
||||
*
|
||||
* @param string $userAgent
|
||||
* @return string
|
||||
*/
|
||||
private function getBrowser(string $userAgent): string
|
||||
{
|
||||
$br = 'Unknown';
|
||||
if (preg_match('/MSIE/i', $userAgent)) {
|
||||
$br = 'MSIE';
|
||||
} elseif (preg_match('/Firefox/i', $userAgent)) {
|
||||
$br = 'Firefox';
|
||||
} elseif (preg_match('/Edg/i', $userAgent)) {
|
||||
$br = 'Edge';
|
||||
} elseif (preg_match('/OPR|Opera/i', $userAgent)) {
|
||||
$br = 'Opera';
|
||||
} elseif (preg_match('/Chrome/i', $userAgent)) {
|
||||
$br = 'Chrome';
|
||||
} elseif (preg_match('/Safari/i', $userAgent)) {
|
||||
$br = 'Safari';
|
||||
} else {
|
||||
$br = 'Other';
|
||||
}
|
||||
return $br;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作系统信息
|
||||
*
|
||||
* @param string $userAgent
|
||||
* @return string
|
||||
*/
|
||||
private function getOs(string $userAgent): string
|
||||
{
|
||||
$os = 'Unknown';
|
||||
if (preg_match('/win/i', $userAgent)) {
|
||||
$os = 'Windows';
|
||||
} elseif (preg_match('/mac/i', $userAgent)) {
|
||||
$os = 'Mac';
|
||||
} elseif (preg_match('/linux/i', $userAgent)) {
|
||||
$os = 'Linux';
|
||||
} else {
|
||||
$os = 'Other';
|
||||
}
|
||||
return $os;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取IP归属地
|
||||
*
|
||||
* @param string $ip
|
||||
* @return string
|
||||
*/
|
||||
private function getIpLocation(string $ip): string
|
||||
{
|
||||
// 本地IP或无效IP直接返回
|
||||
if (empty($ip) || in_array($ip, ['127.0.0.1', '::1', 'localhost', '0.0.0.0'])) {
|
||||
return '本地';
|
||||
}
|
||||
|
||||
// 使用新浪API获取IP归属地
|
||||
try {
|
||||
// 创建Guzzle HTTP客户端
|
||||
$client = new \GuzzleHttp\Client([
|
||||
'timeout' => 2,
|
||||
'connect_timeout' => 1,
|
||||
'verify' => false
|
||||
]);
|
||||
|
||||
$url = "http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=" . $ip;
|
||||
$response = $client->get($url);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (isset($data['ret']) && $data['ret'] === 1 && !empty($data['city'])) {
|
||||
$location = $data['city'];
|
||||
if (!empty($data['province']) && strpos($data['province'], $data['city']) === false) {
|
||||
$location = $data['province'] . ' ' . $location;
|
||||
}
|
||||
return $location;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略异常
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*
|
||||
* @param string|int $id
|
||||
* @param array $data
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateUserInfo(string|int $id, array $data): mixed
|
||||
{
|
||||
return $this->dao->update(['id' => $id], $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户头像
|
||||
*
|
||||
* @param string|int $id
|
||||
* @param string $avatar
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateAvatarUser(string|int $id, string $avatar): mixed
|
||||
{
|
||||
return $this->dao->update(['id' => $id], ['avatar' => $this->getPathFromUrl($avatar)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新个人用户密码
|
||||
*
|
||||
* @param string|int $id
|
||||
* @param array $data
|
||||
*
|
||||
* @return void
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function updateUserPwd(string|int $id, array $data): void
|
||||
{
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$info = $this->dao->getModel()->where('id', $id)->first();
|
||||
if (empty($info)) {
|
||||
throw new AdminException('用户不存在或被删除');
|
||||
}
|
||||
if (!password_verify($data['old_password'], $info->password)) {
|
||||
throw new AdminException('旧密码错误,请重新输入!');
|
||||
}
|
||||
return $this->dao->update(['id' => $id], ['password' => $this->passwordHash($data['new_password'])]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新个人偏好设置
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function updateUserPreferences(string|int $id, array $data = []): void
|
||||
{
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
unset($data['id']);
|
||||
return $this->dao->update(['id' => $id], ['backend_setting' => $data]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制下线
|
||||
*
|
||||
* @param $token
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function kickoutByTokenValueUser($token): void
|
||||
{
|
||||
$this->transaction(function () use ($token) {
|
||||
/** @var LoginLogService $systemLoginLogService */
|
||||
$systemLoginLogService = Container::make(LoginLogService::class);
|
||||
// 使用 firstOrFail 避免空值检查
|
||||
$loginLog = $systemLoginLogService->getModel()
|
||||
->where('key', $token)
|
||||
->firstOrFail();
|
||||
|
||||
// 批量更新字段
|
||||
$loginLog->update([
|
||||
'expires_at' => time(), // 使用 Carbon 时间
|
||||
'remark' => '强制下线',
|
||||
'updated_at' => time(), // 确保更新时间戳
|
||||
]);
|
||||
|
||||
// 添加Token 致黑名单
|
||||
$result = JwtToken::addToBlacklist($token, true);
|
||||
if (!$result) {
|
||||
throw new AdminException('操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除关联源-并清理残留关联数据
|
||||
*
|
||||
* @param array $ids
|
||||
*
|
||||
* @return array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function batchDelete(array $ids): array
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($ids) {
|
||||
// 1. 验证:禁止删除超级管理员
|
||||
$superUserCount = $this->dao->getModel()->whereIn('id', $ids)
|
||||
->where('is_super', 1)
|
||||
->count();
|
||||
if ($superUserCount > 0) {
|
||||
throw new AdminException('系统内置用户不允许删除');
|
||||
}
|
||||
|
||||
// 2. 关联同步:通过模型关联清理角色中间表数据
|
||||
|
||||
$admins = $this->dao->getModel()->whereIn('id', $ids)->get();
|
||||
foreach ($admins as $admin) {
|
||||
// 解除角色关联
|
||||
$admin->roles()->detach();
|
||||
$admin->depts()->detach();
|
||||
}
|
||||
|
||||
// 4. 执行管理员数据删除
|
||||
$deleteCount = $this->dao->destroy($ids);
|
||||
if ($deleteCount <= 0) {
|
||||
throw new AdminException('删除失败,未找到有效用户');
|
||||
}
|
||||
return ['id' => $ids];
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一UUID
|
||||
*
|
||||
* @param string|null $currentIp
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateUniqueId(string|null $currentIp = null): string
|
||||
{
|
||||
if (empty($currentIp)) {
|
||||
$currentIp = request()->getRemoteIp();
|
||||
}
|
||||
return uniqid($currentIp . '-', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 入参移除url
|
||||
*
|
||||
* @param string $url
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getPathFromUrl(string $url): string
|
||||
{
|
||||
// 检测协议头存在性
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
return $url; // 非http(s)协议直接原样返回
|
||||
}
|
||||
// 支持子域名和端口号
|
||||
$pattern = '#^https?://
|
||||
(?:[^/@]+@)? // 忽略用户认证信息
|
||||
(?:[^/:]+|\[[^\]]+\]) // 匹配域名或IPv6地址
|
||||
(?::\d+)? // 忽略端口号
|
||||
(/[^\?#]*) // 精确捕获路径部分
|
||||
#x';
|
||||
preg_match($pattern, $url, $matches);
|
||||
return $matches[1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验密钥
|
||||
*
|
||||
* @param $keyId
|
||||
* @param $encryptedPassword
|
||||
*
|
||||
* @return string
|
||||
* @throws AdminException
|
||||
*/
|
||||
private function validateRsaKeys($keyId, $encryptedPassword): string
|
||||
{
|
||||
$cache = Container::make(CacheService::class, []);
|
||||
$privateKey = $cache->get("rsa_private_key_$keyId");
|
||||
if (!$privateKey) {
|
||||
throw new AdminException('私钥不存在或已过期,请刷新页面重试');
|
||||
}
|
||||
return RSAService::decrypt($encryptedPassword, $privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\adminapi\CurrentUser;
|
||||
use app\dao\system\AdminDao;
|
||||
use core\base\BaseService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use support\Container;
|
||||
|
||||
class AuthService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(AdminDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单
|
||||
*
|
||||
* @param \app\adminapi\CurrentUser $currentUser
|
||||
* @param bool $includeButtons
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getMenusByUserRoles(CurrentUser $currentUser, bool $includeButtons = false): ?Collection
|
||||
{
|
||||
$adminModel = $currentUser->admin();
|
||||
if (!$adminModel) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
$isSuperAdmin = boolval($adminModel->getAttribute('is_super'));
|
||||
/** @var MenuService $menuService */
|
||||
$menuService = Container::make(MenuService::class);
|
||||
$map1 = $includeButtons ? ['type' => [1, 2, 3, 4]] : ['type' => [1, 2]];
|
||||
|
||||
if ($isSuperAdmin) {
|
||||
// 顶级管理员返回全部菜单
|
||||
return $menuService->selectList($map1, '*', 0, 0, 'sort', [], true);
|
||||
}
|
||||
|
||||
// 普通成员 - 通过角色获取菜单
|
||||
$menuIds = $this->getUserMenuIds($adminModel);
|
||||
return $this->getMenusByIds($menuService, array_unique($menuIds), $includeButtons);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户拥有的菜单ID列表
|
||||
*
|
||||
* @param \app\model\system\Admin $adminModel
|
||||
* @return array 菜单ID数组
|
||||
*/
|
||||
private function getUserMenuIds($adminModel): array
|
||||
{
|
||||
$menuIds = [];
|
||||
|
||||
// 通过角色获取菜单
|
||||
$roles = $adminModel->roles()->with('menus')->get();
|
||||
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->menus as $menu) {
|
||||
$menuIds[] = $menu->id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($menuIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据IDS输出菜单
|
||||
*
|
||||
* @param \app\service\admin\system\MenuService $menuService
|
||||
* @param array $ids
|
||||
* @param bool $includeButtons
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getMenusByIds(MenuService $menuService, array $ids, bool $includeButtons = false): Collection
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return new Collection();
|
||||
}
|
||||
$menuModel = $menuService->dao->getModel();
|
||||
$chunkSize = 200;
|
||||
$chunks = array_chunk($ids, $chunkSize);
|
||||
$typeFilter = function ($query) use ($includeButtons) {
|
||||
$types = $includeButtons ? [1, 2, 3, 4] : [1, 2];
|
||||
$query->whereIn('type', $types);
|
||||
};
|
||||
|
||||
// 分块查询 + 批量合并(减少内存占用)
|
||||
$allResults = new Collection();
|
||||
foreach ($chunks as $chunk) {
|
||||
$results = $menuModel
|
||||
->whereIn('id', $chunk)
|
||||
->where('enabled', 1)
|
||||
->where($typeFilter)
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
$allResults = $allResults->merge($results);
|
||||
}
|
||||
return $allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色-权限码
|
||||
*
|
||||
* @param \app\adminapi\CurrentUser $currentUser
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCodesByUserRoles(CurrentUser $currentUser): array
|
||||
{
|
||||
$adminModel = $currentUser->admin();
|
||||
if (!$adminModel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$isSuperAdmin = boolval($adminModel->getAttribute('is_super'));
|
||||
if ($isSuperAdmin) {
|
||||
// 顶级管理员
|
||||
return ['admin'];
|
||||
}
|
||||
|
||||
// 普通成员 - 通过角色获取权限码
|
||||
return $currentUser->getPermissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\enum\system\PolicyPrefix;
|
||||
use core\base\BaseService;
|
||||
use core\casbin\Permission;
|
||||
use core\exception\handler\AdminException;
|
||||
|
||||
class CasbinService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->dao = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新casbin 策略
|
||||
*
|
||||
* @param array $currentPermissions
|
||||
* @param array $incomingPolicies
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function updateCasbinPolicies(array $currentPermissions, array $incomingPolicies): void
|
||||
{
|
||||
// 计算需要删除的权限:当前有但传入没有的
|
||||
$permissionsToDelete = array_filter($currentPermissions, function ($currentPolicy) use ($incomingPolicies) {
|
||||
foreach ($incomingPolicies as $incomingPolicy) {
|
||||
if (
|
||||
(string)$currentPolicy[0] === (string)$incomingPolicy[0] &&
|
||||
(string)$currentPolicy[1] === (string)$incomingPolicy[1] &&
|
||||
(string)$currentPolicy[2] === (string)$incomingPolicy[2] &&
|
||||
(string)$currentPolicy[5] === (string)$incomingPolicy[5]
|
||||
) {
|
||||
return false; // 找到了匹配的,当前权限不需要删除
|
||||
}
|
||||
}
|
||||
return true; // 没有找到匹配的,当前权限需要删除
|
||||
});
|
||||
|
||||
// 计算需要添加的权限:传入有但当前没有的
|
||||
$permissionsToAdd = array_filter($incomingPolicies, function ($incomingPolicy) use ($currentPermissions) {
|
||||
foreach ($currentPermissions as $currentPolicy) {
|
||||
if (
|
||||
(string)$incomingPolicy[0] === (string)$currentPolicy[0] &&
|
||||
(string)$incomingPolicy[1] === (string)$currentPolicy[1] &&
|
||||
(string)$incomingPolicy[2] === (string)$currentPolicy[2] &&
|
||||
(string)$incomingPolicy[5] === (string)$currentPolicy[5]
|
||||
) {
|
||||
return false; // 找到了匹配的,传入权限不需要添加
|
||||
}
|
||||
}
|
||||
return true; // 没有找到匹配的,传入权限需要添加
|
||||
});
|
||||
|
||||
// 删除不需要的权限
|
||||
foreach ($permissionsToDelete as $policy) {
|
||||
Permission::removeFilteredPolicy("0", $policy[0], $policy[1], '', '', '', $policy[5]);
|
||||
}
|
||||
|
||||
// 添加新的权限
|
||||
foreach ($permissionsToAdd as $policy) {
|
||||
$res = Permission::addPolicy(...$policy);
|
||||
if (!$res) {
|
||||
throw new AdminException('添加失败:' . $policy[5]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据菜单ID删除关联Casbin策略
|
||||
*
|
||||
* @param int|string $menuId 菜单ID
|
||||
*
|
||||
* @throws AdminException
|
||||
*/
|
||||
public function deletePoliciesByMenuId(int|string $menuId): void
|
||||
{
|
||||
try {
|
||||
$policyIdentifier = PolicyPrefix::MENU->value . (string)$menuId;
|
||||
Permission::removeFilteredPolicy("0", '', '', '', '', '', $policyIdentifier);
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException("删除菜单关联策略失败:{$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\ConfigDao;
|
||||
use app\scope\global\AccessPermissionScope;
|
||||
use core\base\BaseService;
|
||||
|
||||
class ConfigService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(ConfigDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param mixed $default 默认值
|
||||
* @param array $options 选项,如 ['group_code' => '...']
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function config(string $code, mixed $default = [], array $options = []): mixed
|
||||
{
|
||||
$map = ['code' => $code];
|
||||
// 如果选项中有分组,则添加到查询条件
|
||||
if (!empty($options['group_code'])) {
|
||||
$map['group_code'] = $options['group_code'];
|
||||
}
|
||||
|
||||
// 查询配置
|
||||
$configModel = $this->dao->get($map, ['*'], [], '', [AccessPermissionScope::class]);
|
||||
|
||||
// 如果配置不存在,自动创建
|
||||
if (!$configModel) {
|
||||
$configData = [
|
||||
'code' => $code,
|
||||
'name' => $options['name'] ?? $code,
|
||||
'group_code' => $options['group_code'] ?? 'default',
|
||||
'content' => is_array($default) ? json_encode($default, JSON_UNESCAPED_UNICODE) : $default,
|
||||
'type' => $options['type'] ?? (is_array($default) ? 'json' : 'string'),
|
||||
'enabled' => 1,
|
||||
'sort' => 0,
|
||||
'remark' => $options['remark'] ?? '',
|
||||
];
|
||||
|
||||
try {
|
||||
$this->dao->save($configData);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
$content = $configModel->getOriginal('content', null);
|
||||
|
||||
// 如果content是数组/JSON格式,返回整个数组;否则返回原值
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
}
|
||||
|
||||
return $content ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置分组详情
|
||||
*
|
||||
* @param string $groupCode 配置分组
|
||||
* @param array $defaults 默认配置项
|
||||
* @param array $options 选项参数
|
||||
* - enabled_only: bool 是否只获取启用的配置项,默认true
|
||||
* - with_metadata: bool 是否包含配置元数据,默认false
|
||||
*
|
||||
* @return array
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function getByGroup(string $groupCode, array $defaults = [], array $options = []): array
|
||||
{
|
||||
// 验证groupCode参数,防止SQL注入
|
||||
if (empty($groupCode)) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$enabledOnly = $options['enabled_only'] ?? true;
|
||||
$withMetadata = $options['with_metadata'] ?? false;
|
||||
|
||||
$map = ['group_code' => $groupCode];
|
||||
if ($enabledOnly) {
|
||||
$map['enabled'] = 1;
|
||||
}
|
||||
|
||||
if ($withMetadata) {
|
||||
// 获取完整配置信息
|
||||
$configs = $this->dao->getList($map);
|
||||
$processedData = [];
|
||||
foreach ($configs as $config) {
|
||||
$content = $config['content'] ?? null;
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$config['content'] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
}
|
||||
|
||||
if (isset($options['key_by']) && $options['key_by'] === 'code') {
|
||||
$processedData[$config['code']] = $config;
|
||||
} else {
|
||||
$processedData[] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
return $processedData;
|
||||
} else {
|
||||
// 只获取配置内容
|
||||
$data = $this->dao->getColumn($map, 'content', 'code');
|
||||
// 处理每个配置项的内容,如果是JSON格式则解码
|
||||
$processedData = [];
|
||||
foreach ($data as $code => $content) {
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$processedData[$code] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
} else {
|
||||
$processedData[$code] = $content;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并默认配置
|
||||
return array_merge($defaults, $processedData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有配置并按分组整理
|
||||
*
|
||||
* @param array $options 选项参数
|
||||
* - enabled_only: bool 是否只获取启用的配置项,默认true
|
||||
* - group_filter: array 分组过滤,只返回指定分组的配置
|
||||
* - with_metadata: bool 是否包含配置项的完整元数据,默认false
|
||||
* - key_by: string 按指定字段作为键名('code' 或 'id'),默认null
|
||||
* - sort_groups: bool 是否对分组进行排序,默认false
|
||||
* - sort_configs: bool 是否对配置项进行排序,默认false
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAllGrouped(array $options = []): array
|
||||
{
|
||||
$enabledOnly = $options['enabled_only'] ?? true;
|
||||
$groupFilter = $options['group_filter'] ?? [];
|
||||
$withMetadata = $options['with_metadata'] ?? false;
|
||||
$keyBy = $options['key_by'] ?? null;
|
||||
$sortGroups = $options['sort_groups'] ?? false;
|
||||
$sortConfigs = $options['sort_configs'] ?? false;
|
||||
|
||||
$map = [];
|
||||
if ($enabledOnly) {
|
||||
$map['enabled'] = 1;
|
||||
}
|
||||
if (!empty($groupFilter)) {
|
||||
$map['group_code'] = $groupFilter;
|
||||
}
|
||||
|
||||
$configs = $this->dao->getList($map);
|
||||
$grouped = [];
|
||||
|
||||
foreach ($configs as $config) {
|
||||
$groupCode = $config['group_code'] ?? 'default';
|
||||
|
||||
// 处理内容
|
||||
$content = $config['content'];
|
||||
if (is_string($content) && !empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
$config['content'] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
|
||||
}
|
||||
|
||||
// 初始化分组
|
||||
if (!isset($grouped[$groupCode])) {
|
||||
$grouped[$groupCode] = [];
|
||||
}
|
||||
|
||||
// 根据选项决定返回的数据结构
|
||||
if ($withMetadata) {
|
||||
// 返回完整配置信息
|
||||
if ($keyBy && isset($config[$keyBy])) {
|
||||
$grouped[$groupCode][$config[$keyBy]] = $config;
|
||||
} else {
|
||||
$grouped[$groupCode][] = $config;
|
||||
}
|
||||
} else {
|
||||
// 只返回配置内容
|
||||
if ($keyBy && isset($config[$keyBy])) {
|
||||
$grouped[$groupCode][$config[$keyBy]] = $config['content'];
|
||||
} else {
|
||||
$grouped[$groupCode][$config['code']] = $config['content'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序处理
|
||||
if ($sortGroups) {
|
||||
ksort($grouped);
|
||||
}
|
||||
|
||||
if ($sortConfigs) {
|
||||
foreach ($grouped as &$configs) {
|
||||
if ($withMetadata) {
|
||||
// 对元数据模式按code排序
|
||||
uasort($configs, function ($a, $b) {
|
||||
return strcmp($a['code'] ?? '', $b['code'] ?? '');
|
||||
});
|
||||
} else {
|
||||
// 对内容模式按键名排序
|
||||
ksort($configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项中的某个键值
|
||||
*
|
||||
* @param string $code 配置项
|
||||
* @param string $key 要获取的键,支持多级键,如 key1.key2.key3
|
||||
* @param mixed $default 默认值,默认null
|
||||
* @param array $options 选项参数
|
||||
* - group_code: string 配置分组,默认null(在所有分组中查找)
|
||||
* - require_group: bool 是否必须指定分组,默认false
|
||||
* - fallback_to_all: bool 当未找到分组配置时,是否在所有分组中查找,默认true
|
||||
* - search_groups: array 搜索的分组列表,按顺序查找
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getValue(string $code, string $key, mixed $default = null, array $options = []): mixed
|
||||
{
|
||||
$groupCode = $options['group_code'] ?? null;
|
||||
$requireGroup = $options['require_group'] ?? false;
|
||||
$fallbackToAll = $options['fallback_to_all'] ?? true;
|
||||
$searchGroups = $options['search_groups'] ?? [];
|
||||
|
||||
// 如果指定了搜索分组列表,按顺序查找
|
||||
if (!empty($searchGroups) && is_array($searchGroups)) {
|
||||
foreach ($searchGroups as $searchGroupCode) {
|
||||
$config = $this->config($code, null, ['group_code' => $searchGroupCode]);
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
// 如果指定了分组,优先在该分组中查找
|
||||
if ($groupCode !== null) {
|
||||
$config = $this->config($code, null, ['group_code' => $groupCode]);
|
||||
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果未找到且允许回退到所有分组,则尝试在所有分组中查找
|
||||
if ($fallbackToAll && !$requireGroup) {
|
||||
$config = $this->config($code, null);
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
// 默认在所有分组中查找
|
||||
$config = $this->config($code, null);
|
||||
if (is_array($config)) {
|
||||
$value = $this->getNestedValue($config, $key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置特定配置项中的某个键值
|
||||
* 支持两种调用方式:
|
||||
* 1. 新版本(推荐):setConfigValue(string $code, string $key, mixed $value, array $options = [])
|
||||
* 2. 旧版本(兼容):setConfigValue(string $groupCode, string $code, string $key, mixed $value)
|
||||
*
|
||||
* @param string $code 配置项或配置分组(旧版本)
|
||||
* @param string $key 要设置的键,支持多级键,如 key1.key2.key3 或配置项(旧版本)
|
||||
* @param mixed $value 要设置的值或要设置的键(旧版本)
|
||||
* @param array|mixed $options 选项参数或要设置的值(旧版本)
|
||||
* - group_code: string 配置分组,默认null
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function setValue(string $code, string $key, mixed $value, mixed $options = []): bool
|
||||
{
|
||||
// 检查是否为旧版本调用方式:setConfigValue($groupCode, $code, $key, $value)
|
||||
if (func_num_args() === 4 && is_string($options)) {
|
||||
// 旧版本调用,参数顺序为:$groupCode, $code, $key, $value
|
||||
$groupCode = $code;
|
||||
$code = $key;
|
||||
$key = $value;
|
||||
$value = $options;
|
||||
$options = ['group_code' => $groupCode];
|
||||
}
|
||||
|
||||
$groupCode = $options['group_code'] ?? null;
|
||||
|
||||
// 验证groupCode参数,防止SQL注入
|
||||
if ($groupCode !== null && !preg_match('/^[a-zA-Z0-9_-]+$/', $groupCode)) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
|
||||
$config = $this->config($code, [], ['group_code' => $groupCode]);
|
||||
|
||||
if (is_array($config)) {
|
||||
// 支持多级键设置
|
||||
$updatedConfig = $this->setNestedValue($config, $key, $value);
|
||||
} else {
|
||||
// 如果当前配置不是数组,将其转换为数组
|
||||
$updatedConfig = $this->setNestedValue([], $key, $value);
|
||||
}
|
||||
|
||||
// 更新配置,传递groupCode参数
|
||||
return $this->update($code, $updatedConfig, ['group_code' => $groupCode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据多级键获取嵌套数组中的值
|
||||
*
|
||||
* @param array $array 要查找的数组
|
||||
* @param string $key 多级键,如 key1.key2.key3
|
||||
*
|
||||
* @return mixed|null 找到的值或null
|
||||
*/
|
||||
private function getNestedValue(array $array, string $key): mixed
|
||||
{
|
||||
// 如果key不包含点号,直接返回对应值
|
||||
if (!str_contains($key, '.')) {
|
||||
return $array[$key] ?? null;
|
||||
}
|
||||
|
||||
// 拆分多级键
|
||||
$keys = explode('.', $key);
|
||||
$value = $array;
|
||||
|
||||
// 逐级查找
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !isset($value[$k])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据多级键设置嵌套数组中的值
|
||||
*
|
||||
* @param array $array 要设置值的数组
|
||||
* @param string $key 多级键,如 key1.key2.key3
|
||||
* @param mixed $value 要设置的值
|
||||
*
|
||||
* @return array 更新后的数组
|
||||
*/
|
||||
private function setNestedValue(array $array, string $key, mixed $value): array
|
||||
{
|
||||
// 如果key不包含点号,直接设置值
|
||||
if (!str_contains($key, '.')) {
|
||||
$array[$key] = $value;
|
||||
return $array;
|
||||
}
|
||||
|
||||
// 拆分多级键
|
||||
$keys = explode('.', $key);
|
||||
$lastKey = array_pop($keys);
|
||||
$current = &$array;
|
||||
|
||||
// 逐级创建或获取子数组
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($current[$k]) || !is_array($current[$k])) {
|
||||
$current[$k] = [];
|
||||
}
|
||||
$current = &$current[$k];
|
||||
}
|
||||
|
||||
// 设置最终值
|
||||
$current[$lastKey] = $value;
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param mixed $content 配置内容
|
||||
* @param array $options 扩展选项参数
|
||||
* - group_code: string 配置分组
|
||||
* - name: string 配置名称
|
||||
* - enabled: int 是否启用 (1=启用, 0=禁用)
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update(string $code, mixed $content, array $options = []): void
|
||||
{
|
||||
try {
|
||||
// 处理内容:如果是数组则转换为JSON,字符串直接存储
|
||||
$processedContent = $content;
|
||||
if (is_array($content)) {
|
||||
$processedContent = json_encode($content, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
$map = ['code' => $code];
|
||||
$configModel = $this->dao->get($map);
|
||||
|
||||
if (!empty($configModel)) {
|
||||
// 更新内容
|
||||
$configModel->content = $processedContent;
|
||||
// 更新其他可选字段
|
||||
if (isset($options['group_code'])) {
|
||||
// 验证group_code格式
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
$configModel->group_code = $options['group_code'];
|
||||
}
|
||||
|
||||
if (isset($options['name'])) {
|
||||
$configModel->name = $options['name'];
|
||||
}
|
||||
|
||||
if (isset($options['enabled'])) {
|
||||
$configModel->enabled = (int)$options['enabled'];
|
||||
}
|
||||
|
||||
$configModel->save();
|
||||
} else {
|
||||
// 创建新的配置项
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'content' => $processedContent,
|
||||
'name' => $options['name'] ?? $code,
|
||||
'enabled' => isset($options['enabled']) ? (int)$options['enabled'] : 1,
|
||||
];
|
||||
|
||||
// 设置可选的分组
|
||||
if (isset($options['group_code'])) {
|
||||
// 验证group_code格式
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
$data['group_code'] = $options['group_code'];
|
||||
}
|
||||
|
||||
$this->dao->save($data);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置项是否存在
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param array $options 扩展选项参数
|
||||
* - group_code: string 配置分组
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function exists(string $code, array $options = []): bool
|
||||
{
|
||||
$map = ['code' => $code];
|
||||
if (isset($options['group_code'])) {
|
||||
// 验证group_code格式
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
$map['group_code'] = $options['group_code'];
|
||||
}
|
||||
|
||||
$config = $this->dao->get($map);
|
||||
return !empty($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置项
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param array $options 扩展参数
|
||||
* - group_code: string 配置分组
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(string $code, array $options = []): bool|int
|
||||
{
|
||||
$map = [['code' ,'EQ', $code]];
|
||||
if (isset($options['group_code'])) {
|
||||
// 验证group_code格式
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
$map[] = ['group_code','EQ',$options['group_code']];
|
||||
}
|
||||
$config = $this->dao->get($map);
|
||||
if (!$config) {
|
||||
return false;
|
||||
}
|
||||
// 验证是否为系统配置,系统配置不允许删除
|
||||
if (isset($config->is_sys) && $config->is_sys == 1) {
|
||||
throw new \Exception('系统配置不允许删除');
|
||||
}
|
||||
|
||||
return $this->dao->delete($config['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用配置项
|
||||
*
|
||||
* @param string $code 配置code
|
||||
* @param int $enabled
|
||||
* @param array $options 扩展参数
|
||||
* - group_code: string 配置分组
|
||||
* - enabled: bool 是否启用,默认true
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function enabled(string $code, int $enabled, array $options = []): bool
|
||||
{
|
||||
$map = ['code' => $code];
|
||||
if (isset($options['group_code'])) {
|
||||
// 验证group_code格式
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
|
||||
throw new \Exception('Invalid group_code format');
|
||||
}
|
||||
$map['group_code'] = $options['group_code'];
|
||||
}
|
||||
|
||||
$config = $this->dao->get($map);
|
||||
if (!$config) {
|
||||
return false;
|
||||
}
|
||||
$config->enabled = $enabled;
|
||||
return $config->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项列表(分页)
|
||||
*
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页数量
|
||||
* @param string $groupCode 分组代码
|
||||
* @param string $keyword 搜索关键字
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getItems(int $page, int $pageSize, string $groupCode, string $keyword = ''): array
|
||||
{
|
||||
|
||||
$map1 = [['is_sys', 'ne', 1]];
|
||||
if (!empty($keyword)) {
|
||||
$map1[] = ['code', 'eq', $keyword];
|
||||
}
|
||||
|
||||
$list = $this->dao->getList($map1, ['*'], $page, $pageSize, 'code', [], false);
|
||||
$total = $this->dao->count($map1);
|
||||
// 处理每个配置项的值
|
||||
foreach ($list as &$item) {
|
||||
$item['value'] = $this->formatValue($item['content'], $item['type'] ?? 'string');
|
||||
}
|
||||
return [
|
||||
'items' => $list,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个配置项
|
||||
*
|
||||
* @param array $data 配置数据
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createItem(array $data): void
|
||||
{
|
||||
// 检查配置代码是否已存在
|
||||
if ($this->exists($data['code'], ['group_code' => $data['group_code']])) {
|
||||
throw new \Exception('配置代码已存在');
|
||||
}
|
||||
|
||||
$this->dao->save($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个配置项
|
||||
*
|
||||
* @param int $id 配置ID
|
||||
* @param array $data 配置数据
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateItem(int $id, array $data): void
|
||||
{
|
||||
$config = $this->dao->get(['id' => $id]);
|
||||
if (!$config) {
|
||||
throw new \Exception('配置不存在');
|
||||
}
|
||||
|
||||
// 验证是否为系统配置
|
||||
if (isset($config->is_sys) && $config->is_sys == 1) {
|
||||
throw new \Exception('系统配置不允许修改');
|
||||
}
|
||||
|
||||
$config->name = $data['name'];
|
||||
$config->content = $data['content'];
|
||||
$config->type = $data['type'] ?? 'string';
|
||||
$config->description = $data['description'] ?? '';
|
||||
$config->enabled = $data['enabled'] ?? 1;
|
||||
|
||||
$config->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置项
|
||||
*
|
||||
* @param int $id 配置ID
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deleteItem(int $id): void
|
||||
{
|
||||
$config = $this->dao->get(['id' => $id]);
|
||||
if (!$config) {
|
||||
throw new \Exception('配置不存在');
|
||||
}
|
||||
|
||||
// 验证是否为系统配置
|
||||
if (isset($config->is_sys) && $config->is_sys == 1) {
|
||||
throw new \Exception('系统配置不允许删除');
|
||||
}
|
||||
|
||||
$this->dao->delete($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换配置项启用状态
|
||||
*
|
||||
* @param int $id 配置ID
|
||||
* @param bool $enabled 是否启用
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toggleItem(int $id, bool $enabled): void
|
||||
{
|
||||
$config = $this->dao->get(['id' => $id]);
|
||||
if (!$config) {
|
||||
throw new \Exception('配置不存在');
|
||||
}
|
||||
|
||||
$config->enabled = $enabled ? 1 : 0;
|
||||
$config->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化配置值
|
||||
*
|
||||
* @param mixed $content 配置内容
|
||||
* @param string $type 配置类型
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
private function formatValue(mixed $content, string $type): mixed
|
||||
{
|
||||
return match ($type) {
|
||||
'string' => (string)$content,
|
||||
'number' => (float)$content,
|
||||
'boolean' => (bool)$content,
|
||||
'json' => is_string($content) ? json_decode($content, true) : $content,
|
||||
default => $content
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\FeedbackDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
class FeedbackService extends BaseService
|
||||
{
|
||||
public function __construct(FeedbackDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\MenuDao;
|
||||
use app\model\system\Menu;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use madong\helper\Arr;
|
||||
use madong\helper\Tree;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* @method save(array $data)
|
||||
* @method selectModel(array $where, array|string $field = '*', int $page = 0, int $limit = 0, string $order = '', array $with = [], bool $search = false, ?array $withoutScopes = null)
|
||||
*/
|
||||
class MenuService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 缓存key
|
||||
*/
|
||||
const CACHE_KEY = 'menu_list';
|
||||
|
||||
/**
|
||||
* 所有权限
|
||||
*/
|
||||
const CACHE_ALL_AUTHS_DATA = 'all_auths_data';
|
||||
|
||||
/**
|
||||
* 所有菜单
|
||||
*/
|
||||
const CACHE_ALL_MENUS_DATA = 'all_menus_data';
|
||||
|
||||
public function __construct(MenuDao $dao)
|
||||
{
|
||||
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出所有权限
|
||||
*
|
||||
* @param array $menuIds
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAllAuth(array $menuIds = []): array
|
||||
{
|
||||
// 1. 优先从缓存获取所有权限数据(自动处理缓存未命中时的数据库查询)
|
||||
$allAuthItems = $this->cacheDriver()->remember(
|
||||
self::CACHE_ALL_AUTHS_DATA,
|
||||
function () {
|
||||
// 缓存未命中时,从数据库获取数据(仅查询需要的字段)
|
||||
return $this->dao->getModel()
|
||||
->where('path', '<>', '')
|
||||
->where('type', '=', 4)
|
||||
->get(['id', 'path', 'methods'])
|
||||
->toArray();
|
||||
}
|
||||
);
|
||||
|
||||
// 2. 如果传入了 $menuIds,则筛选出对应的权限项
|
||||
if (!empty($menuIds)) {
|
||||
$allAuthItems = array_filter($allAuthItems, function ($item) use ($menuIds) {
|
||||
return in_array($item['id'], $menuIds);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 格式化输出(复用现有方法)
|
||||
return $this->formatAuthDataFromItems($allAuthItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据授权项数组格式化输出为 ['METHOD' => ['/path1', '/path2'], ...] 的形式。
|
||||
*
|
||||
* @param array $allAuthItems 授权项数组,每个元素包含 'id', 'path', 'methods'
|
||||
*
|
||||
* @return array 格式化后的授权数据
|
||||
*/
|
||||
private function formatAuthDataFromItems(array $allAuthItems): array
|
||||
{
|
||||
$allAuth = [];
|
||||
foreach ($allAuthItems as $item) {
|
||||
// 假设 'methods' 是逗号分隔的字符串,如 "GET,POST"
|
||||
$methodArray = explode(',', $item['methods']); // 转换为数组
|
||||
|
||||
$pathKey = strtolower(trim(str_replace(' ', '', $item['path'])));
|
||||
|
||||
foreach ($methodArray as $method) {
|
||||
$methodKey = strtolower(trim($method));
|
||||
$allAuth[$methodKey][] = $pathKey;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果需要,可以对每个方法的路径数组进行去重
|
||||
foreach ($allAuth as &$paths) {
|
||||
$paths = array_unique($paths);
|
||||
}
|
||||
unset($paths); // 解除引用
|
||||
return $allAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有菜单,优先从缓存读取,并根据 $where 条件筛选。
|
||||
*
|
||||
* @param array $where 筛选条件数组,键为字段名,值为条件值或数组(用于 IN 条件)或操作符数组(如 ['>', 5])
|
||||
*
|
||||
* @return array 处理后的菜单树形结构
|
||||
*/
|
||||
// public function getAllMenus(array $where = [], $mode = 'menu', $isTree = true): array
|
||||
// {
|
||||
// // 1. 优先从缓存获取所有菜单数据
|
||||
// $allMenus = $this->cacheDriver()->remember(
|
||||
// self::CACHE_ALL_MENUS_DATA, // 缓存键名
|
||||
// function () {
|
||||
// $list = $this->dao->getModel()
|
||||
// ->where('enabled', 1)
|
||||
//// ->whereIn('type', [1, 2])// 目录 & 菜单类型 全量保存缓存条件放到缓存输出
|
||||
// ->orderBy('sort', 'asc')
|
||||
// ->get();
|
||||
// foreach ($list as $item) {
|
||||
// $item->set('name', $item->code);
|
||||
// $item->set('meta', SysMenu::getMetaAttribute($item));
|
||||
// }
|
||||
// $list->makeVisible(['id', 'pid', 'type', 'sort', 'redirect', 'path', 'name', 'meta', 'component']);
|
||||
// return $list->toArray();
|
||||
// }
|
||||
// );
|
||||
//
|
||||
// // 2. 根据 $where 条件筛选菜单数据
|
||||
// $menusToProcess = Arr::filterByWhere($allMenus, $where);
|
||||
//
|
||||
// if ($mode == 'code') {
|
||||
// return array_column($menusToProcess, 'code');
|
||||
// }
|
||||
// if (!$isTree) {
|
||||
// return $menusToProcess;
|
||||
// }
|
||||
//
|
||||
// // 构建树形结构
|
||||
// return $this->buildMenuTree($menusToProcess);
|
||||
//
|
||||
// }
|
||||
|
||||
public function getAllMenus(array $where = [], $mode = 'menu', $isTree = true): array
|
||||
{
|
||||
// 1. 优先从缓存获取所有菜单数据
|
||||
$allMenus = $this->cacheDriver()->remember(
|
||||
self::CACHE_ALL_MENUS_DATA, // 缓存键名
|
||||
function () {
|
||||
return $this->dao->getModel()
|
||||
->where('enabled', 1)
|
||||
// ->whereIn('type', [1, 2])// 目录 & 菜单类型 全量保存缓存条件放到缓存输出
|
||||
->orderBy('sort', 'asc')
|
||||
->get();
|
||||
// foreach ($list as $item) {
|
||||
// $item->set('name', $item->code);
|
||||
// $item->set('meta', SysMenu::getMetaAttribute($item));
|
||||
// }
|
||||
// $list->makeVisible(['id', 'pid', 'type', 'sort', 'redirect', 'path', 'name', 'meta', 'component']);
|
||||
// return $list->toArray();
|
||||
}
|
||||
);
|
||||
|
||||
// 2. 根据 $where 条件筛选菜单数据
|
||||
$menusToProcess = Arr::filterByWhere($allMenus, $where);
|
||||
|
||||
if ($mode == 'code') {
|
||||
return array_column($menusToProcess, 'code');
|
||||
}
|
||||
if (!$isTree) {
|
||||
return $menusToProcess;
|
||||
}
|
||||
|
||||
// 构建树形结构
|
||||
return $this->buildMenuTree($menusToProcess);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建菜单的树形结构。
|
||||
*
|
||||
* @param array $formattedMenus 已处理的菜单数据
|
||||
*
|
||||
* @return array 树形结构的菜单数据
|
||||
*/
|
||||
protected function buildMenuTree(array $formattedMenus): array
|
||||
{
|
||||
$tree = new Tree($formattedMenus);
|
||||
return $tree->getTree();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*** 重写方法***/
|
||||
|
||||
/**
|
||||
* 重写更新方法:统一事务管理菜单更新、缓存清理、Casbin同步
|
||||
*
|
||||
* @param int|string $id 菜单ID
|
||||
* @param array $data 更新数据
|
||||
*
|
||||
* @return \app\model\system\Menu 更新后的模型
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update(int|string $id, array $data): \app\model\system\Menu
|
||||
{
|
||||
return $this->transaction(function () use ($id, $data) {
|
||||
// 1. 获取菜单模型
|
||||
$model = $this->get($id);
|
||||
if (!$model) {
|
||||
throw new AdminException("菜单ID:{$id}不存在");
|
||||
}
|
||||
|
||||
// 2. 更新菜单数据(使用属性复制或直接赋值)
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
|
||||
// 3. 清理缓存(原控制器中的缓存删除逻辑迁移至此)
|
||||
$this->cacheDriver()->delete(self::CACHE_ALL_AUTHS_DATA);
|
||||
$this->cacheDriver()->delete(self::CACHE_ALL_MENUS_DATA);
|
||||
|
||||
// 4. 同步Casbin策略(复用已实现的同步方法)
|
||||
$this->syncPermissionCacheAfterUpdate($id);
|
||||
return $model;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除菜单(含子菜单)并同步删除Casbin策略
|
||||
*
|
||||
* @param array $data 菜单ID数组或逗号分隔字符串
|
||||
*
|
||||
* @return array 被删除的所有菜单ID
|
||||
* @throws \Throwable
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function batchDelete(array $data = []): array
|
||||
{
|
||||
|
||||
return $this->transaction(function () use ($data) {
|
||||
$deletedIds = [];
|
||||
|
||||
foreach ($data as $id) {
|
||||
/** @var Menu $item */
|
||||
$item = $this->get($id);
|
||||
if (!$item) {
|
||||
continue;
|
||||
}
|
||||
// 删除菜单及子菜单,获取所有被删除的ID
|
||||
$ids = $item->deleteWithAllChildren();
|
||||
$deletedIds = array_merge($deletedIds, $ids);
|
||||
}
|
||||
|
||||
// 清理角色菜单中间表关联数据
|
||||
if (!empty($deletedIds)) {
|
||||
/** @var RoleMenuService $roleMenuService */
|
||||
$roleMenuService = Container::make(RoleMenuService::class);
|
||||
/** @var RoleMenuModel $roleMenuModel */
|
||||
$roleMenuModel = $roleMenuService->getModel();
|
||||
$roleMenuModel->whereIn('menu_id', $deletedIds)->delete();
|
||||
|
||||
// 清理相关用户的权限缓存
|
||||
$this->clearUserPermissionCacheByMenus($deletedIds);
|
||||
}
|
||||
|
||||
return array_unique($deletedIds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单更新后清理相关用户权限缓存
|
||||
*
|
||||
* @param int|string $menuId 被更新的菜单ID
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function syncPermissionCacheAfterUpdate(int|string $menuId): void
|
||||
{
|
||||
try {
|
||||
// 1. 查询所有关联此菜单的角色ID(通过角色菜单服务)
|
||||
/** @var RoleMenuService $roleMenuService */
|
||||
$roleMenuService = Container::make(RoleMenuService::class);
|
||||
$roleIds = $roleMenuService->getColumn(
|
||||
['menu_id' => $menuId],
|
||||
'role_id'
|
||||
);
|
||||
if (empty($roleIds)) {
|
||||
return; // 无关联角色,无需同步
|
||||
}
|
||||
|
||||
// 2. 清理拥有这些角色的用户的权限缓存
|
||||
foreach ($roleIds as $roleId) {
|
||||
/** @var \app\service\admin\system\AdminRoleService $adminRoleService */
|
||||
$adminRoleService = Container::make(\app\service\admin\system\AdminRoleService::class);
|
||||
|
||||
// 获取拥有此角色的用户ID列表
|
||||
$userIds = \app\model\system\AdminRole::where('role_id', $roleId)
|
||||
->pluck('admin_id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($userIds)) {
|
||||
// 清理这些用户的权限缓存
|
||||
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
|
||||
foreach ($userIds as $userId) {
|
||||
$currentUser->clearCache($userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理与指定菜单相关的用户权限缓存
|
||||
*
|
||||
* @param array $menuIds 菜单ID数组
|
||||
*/
|
||||
private function clearUserPermissionCacheByMenus(array $menuIds): void
|
||||
{
|
||||
try {
|
||||
if (empty($menuIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取拥有这些菜单的角色ID
|
||||
/** @var RoleMenuService $roleMenuService */
|
||||
$roleMenuService = Container::make(RoleMenuService::class);
|
||||
/** @var RoleMenuModel $roleMenuModel */
|
||||
$roleMenuModel = $roleMenuService->getModel();
|
||||
|
||||
$roleIds = $roleMenuModel->whereIn('menu_id', $menuIds)
|
||||
->pluck('role_id')
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
if (!empty($roleIds)) {
|
||||
// 获取拥有这些角色的用户ID
|
||||
$userIds = \app\model\system\AdminRole::whereIn('role_id', $roleIds)
|
||||
->pluck('admin_id')
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
if (!empty($userIds)) {
|
||||
// 清理这些用户的权限缓存
|
||||
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
|
||||
foreach ($userIds as $userId) {
|
||||
$currentUser->clearCache($userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 缓存清理失败不影响主要业务,记录日志即可
|
||||
\core\logger\Logger::error("清理菜单相关用户权限缓存失败: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\RecycleBinDao;
|
||||
use app\model\system\RecycleBin;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 回收站服务
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class RecycleBinService extends BaseService
|
||||
{
|
||||
public function __construct(RecycleBinDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据恢复
|
||||
*
|
||||
* @param int $recycleId
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function restoreRecycleBin(int $recycleId): void
|
||||
{
|
||||
$this->transaction(function () use ($recycleId) {
|
||||
$record = $this->dao->get($recycleId, null, [], '');
|
||||
if (empty($record)) {
|
||||
throw new AdminException("回收站记录不存在");
|
||||
}
|
||||
// 3. 动态恢复数据
|
||||
$this->restoreOriginalData($record);
|
||||
|
||||
// 4. 删除回收站记录
|
||||
$record->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复原始数据
|
||||
*/
|
||||
protected function restoreOriginalData(RecycleBin $record): void
|
||||
{
|
||||
$tableName = $record->table_name;
|
||||
$config = self::getTableConfig($tableName);
|
||||
$storage_mode = $config['storage_mode'];
|
||||
$connection = config('database.default');
|
||||
$tableData = json_decode($record->getData('data'), true);
|
||||
$columns = $this->getTableColumns($tableName, $connection);
|
||||
$tableData = array_intersect_key($tableData, array_flip($columns));
|
||||
Db::connection($connection)->table($tableName)->insert($tableData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取还原表的数据列
|
||||
*
|
||||
* @param string $tableName
|
||||
* @param string $connection
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getTableColumns(string $tableName, string $connection = ''): array
|
||||
{
|
||||
return Db::connection($connection)->getSchemaBuilder()->getColumnListing($tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表配置(合并全局+表级配置)
|
||||
*
|
||||
* @param string $table
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
// 获取合并配置
|
||||
public static function getTableConfig(string $table): array
|
||||
{
|
||||
return array_merge(
|
||||
config('recycle_bin.default'),
|
||||
config("recycle_bin.tables.{$table}", []),
|
||||
['tenant' => config('recycle_bin.tenant')]
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\RoleMenuDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* @method getColumn(int[]|string[] $array, string $string)
|
||||
*/
|
||||
class RoleMenuService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(RoleMenuDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设置角色权限
|
||||
*
|
||||
* @param $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
// public function save($data): void
|
||||
// {
|
||||
// try {
|
||||
// $this->transaction(function () use ($data) {
|
||||
// $roleId = $data['role_id'] ?? '';
|
||||
// $newPermissions = $data['menu_id'] ?? [];
|
||||
// if (empty($roleId)) {
|
||||
// throw new AdminException('参数错误缺少role_id', ['errorCode' => -1]);
|
||||
// }
|
||||
//
|
||||
// // 1. 获取角色模型(确保角色存在)
|
||||
// /** @var SysRoleService $roleService */
|
||||
// $roleService = Container::make(SysRoleService::class);
|
||||
// $roleModel = $roleService->get($roleId);
|
||||
// if (!$roleModel) {
|
||||
// throw new AdminException('角色不存在', ['errorCode' => -1]);
|
||||
// }
|
||||
//
|
||||
// // 2. 同步 role_menu 关联关系(自动处理新增/删除,无需手动计算差异)
|
||||
// // 假设 SysRole 模型定义了 roleMenus 关联(hasMany: SysRoleMenu)
|
||||
// $roleModel->menus()->sync($newPermissions);
|
||||
//
|
||||
// // 3. 获取同步后的菜单ID(从关联关系中直接获取,确保数据一致性)
|
||||
// $syncedMenuIds = $roleModel->menus()->pluck('menu_id')->toArray();
|
||||
//
|
||||
// // 4. 构建 Casbin 策略(基于同步后的最新菜单权限)
|
||||
// $casbinRoleId = PolicyPrefix::ROLE->value . $roleId;
|
||||
// $domain = '*';
|
||||
//
|
||||
// // 4.1 获取菜单详情(用于构建策略)
|
||||
// /** @var SysMenuService $menuService */
|
||||
// $menuService = Container::make(SysMenuService::class);
|
||||
// $menuDetails = $menuService->selectList(['id' => $syncedMenuIds], '*', 0, 0, 'sort', [], true)->toArray();
|
||||
//
|
||||
// $incomingPolicies = [];
|
||||
// foreach ($menuDetails as $menu) {
|
||||
// if (empty($menu['code'])) continue;
|
||||
// $incomingPolicies[] = [
|
||||
// $casbinRoleId, // sub (角色标识)
|
||||
// $domain, // dom (租户域)
|
||||
// $menu['code'], // obj (权限码)
|
||||
// '*', // act (操作类型)
|
||||
// $menu['methods'], // method (HTTP方法)
|
||||
// PolicyPrefix::MENU->value . $menu['id'], // trace_id
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// // 4.2 更新 Casbin 策略
|
||||
// $currentCasbinPolicies = Permission::getImplicitResourcesForUser($casbinRoleId, $domain);
|
||||
// /** @var SysCasbinService $casbinService */
|
||||
// $casbinService = Container::make(SysCasbinService::class);
|
||||
// $casbinService->updateCasbinPolicies($currentCasbinPolicies, $incomingPolicies);
|
||||
//
|
||||
// });
|
||||
// } catch (\Throwable $e) {
|
||||
// throw new AdminException($e->getMessage());
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 更新设置角色权限(含事务封装)
|
||||
*
|
||||
* @param $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function save($data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($data) {
|
||||
$this->saveWithoutTransaction($data);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无事务版本:核心业务逻辑(供内部/外部事务复用)
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function saveWithoutTransaction(array $data): void
|
||||
{
|
||||
try {
|
||||
$roleId = $data['role_id'] ?? '';
|
||||
$newPermissions = $data['menu_id'] ?? [];
|
||||
if (empty($roleId)) {
|
||||
throw new AdminException('参数错误缺少role_id');
|
||||
}
|
||||
// 1. 获取角色模型(确保角色存在)
|
||||
/** @var RoleService $roleService */
|
||||
$roleService = Container::make(RoleService::class);
|
||||
$roleModel = $roleService->get($roleId);
|
||||
if (!$roleModel) {
|
||||
throw new AdminException('角色不存在');
|
||||
}
|
||||
|
||||
// 2. 同步 role_menu 关联关系(自动处理新增/删除,无需手动计算差异)
|
||||
$roleModel->menus()->sync($newPermissions);
|
||||
|
||||
// 3. 清理相关用户的权限缓存
|
||||
$this->clearUserPermissionCacheByRole($roleId);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理拥有此角色的用户的权限缓存
|
||||
*
|
||||
* @param string|int $roleId 角色ID
|
||||
*/
|
||||
private function clearUserPermissionCacheByRole(string|int $roleId): void
|
||||
{
|
||||
try {
|
||||
// 获取拥有此角色的所有用户
|
||||
/** @var \app\service\admin\system\AdminRoleService $adminRoleService */
|
||||
$adminRoleService = Container::make(\app\service\admin\system\AdminRoleService::class);
|
||||
|
||||
// 获取拥有此角色的用户ID列表
|
||||
$userIds = \app\model\system\AdminRole::where('role_id', $roleId)
|
||||
->pluck('admin_id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($userIds)) {
|
||||
// 清理这些用户的权限缓存
|
||||
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
|
||||
foreach ($userIds as $userId) {
|
||||
$currentUser->clearCache($userId);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 缓存清理失败不影响主要业务,记录日志即可
|
||||
\core\logger\Logger::error("清理角色{$roleId}的用户权限缓存失败: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\RoleScopeDeptDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* @method getColumn(int[]|string[] $array, string $string)
|
||||
*/
|
||||
class RoleScopeDeptService extends BaseService
|
||||
{
|
||||
|
||||
public function __construct(RoleScopeDeptDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\RoleDao;
|
||||
use app\model\system\Role;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use madong\helper\Arr;
|
||||
use madong\helper\PropertyCopier;
|
||||
use support\Container;
|
||||
|
||||
class RoleService extends BaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 缓存key
|
||||
*/
|
||||
const CACHE_ALL_ROLES_DATA = 'roles_all_data';
|
||||
|
||||
public function __construct(RoleDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有的角色
|
||||
*
|
||||
* @param array $where
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAllRoles(array $where = []): array
|
||||
{
|
||||
// 1. 优先从缓存获取所有角色数据
|
||||
$allRoles = $this->cacheDriver()->remember(
|
||||
self::CACHE_ALL_ROLES_DATA, // 缓存键名
|
||||
function () {
|
||||
$results = $this->dao->getModel()
|
||||
->where('enabled', 1)
|
||||
->orderBy('sort', 'asc')
|
||||
->get(["id",
|
||||
"pid",
|
||||
"name",
|
||||
"code",
|
||||
"is_super_admin",
|
||||
"role_type",
|
||||
"data_scope",
|
||||
"enabled",
|
||||
"sort"]);
|
||||
|
||||
return $results->toArray();
|
||||
}
|
||||
);
|
||||
// 2.$where 条件筛数据
|
||||
return Arr::filterByWhere($allRoles, $where);
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入角色id获取权限
|
||||
*
|
||||
* @param array $where
|
||||
* @param string $mode merge|unique
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getPermissions(array $where = [], string $mode = 'merge'): array
|
||||
{
|
||||
// 1. 优先从缓存获取所有全量角色数据
|
||||
$data = $this->cacheDriver()->remember(
|
||||
self::CACHE_ALL_ROLES_DATA, // 缓存键名
|
||||
function () {
|
||||
return $this->dao->getRolePermissions();
|
||||
}
|
||||
);
|
||||
//2. 条件过滤
|
||||
$result = Arr::filterByWhere($data, $where);
|
||||
if ($mode == 'unique') {
|
||||
return $result['menu'] ?? [];
|
||||
}
|
||||
return [
|
||||
'menu' => array_merge([], ...array_column($result, 'menu')),
|
||||
'api' => array_merge([], ...array_column($result, 'api')),
|
||||
'button' => array_merge([], ...array_column($result, 'button')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过角色id 获取菜单权限IDS
|
||||
*
|
||||
* @param int|string $id
|
||||
* @param string|null $tenantId
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getPermissionColumns(int|string $id, string|null $tenantId = null): array
|
||||
{
|
||||
$role = $this->dao->get($id);
|
||||
if (!$role) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 通过关联关系获取菜单ID
|
||||
return $role->menus()->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* save
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return Role|null
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function save(array $data): Role|null
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($data) {
|
||||
$model = $this->dao->save($data);
|
||||
$handlePermissions = isset($data['permissions']) && $data['permissions'] !== null;
|
||||
|
||||
// 有传权限key,才处理权限
|
||||
if ($handlePermissions) {
|
||||
$menus = $data['permissions'];
|
||||
// 同步角色菜单关联
|
||||
$model->menus()->sync($menus);
|
||||
|
||||
// 清理相关用户的权限缓存
|
||||
$this->clearUserPermissionCacheByRole($model->id);
|
||||
}
|
||||
return $model;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
* @param $id
|
||||
* @param $data
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
// public function update($id, $data): void
|
||||
// {
|
||||
// try {
|
||||
// $this->transaction(function () use ($id, $data) {
|
||||
// // 获取传入的权限列表(如果存在)
|
||||
// $menus = $data['permissions'] ?? [];
|
||||
// // 从数据访问对象中获取模型实例
|
||||
// $model = $this->dao->get($id);
|
||||
// if (!$model) {
|
||||
// return;
|
||||
// }
|
||||
// // 使用属性复制器将传入的数据复制到模型中
|
||||
// PropertyCopier::copyProperties((object)$data, $model);
|
||||
// $model->save();
|
||||
//
|
||||
// // 格式化用户标识符,用于 Casbin
|
||||
// $roleId = PolicyPrefix::ROLE->value . strval($model->id);
|
||||
// $domain = '*';
|
||||
//
|
||||
// // 获取当前用户在 Casbin 中的隐式资源权限
|
||||
// $currentPolicies = Permission::getImplicitResourcesForUser($roleId, $domain);
|
||||
//
|
||||
// // 提取完整的当前权限(0-5)
|
||||
// $currentPermissions = array_map(
|
||||
// function ($policy) {
|
||||
// return $policy; // 保留完整的策略数组
|
||||
// },
|
||||
// $currentPolicies
|
||||
// );
|
||||
//
|
||||
// // 将传入的菜单数据转换为 Casbin 策略格式
|
||||
// $menuService = new SysMenuService();
|
||||
// $policyService = new SysCasbinService();
|
||||
//
|
||||
// $menusData = $menuService->getAllMenus(['id' => $menus], 'menu', false);
|
||||
// // 构建传入的权限列表,格式与当前权限相同
|
||||
// $incomingPolicies = [];
|
||||
// foreach ($menusData as $item) {
|
||||
// $path = $policyService->resolvePermissionIdentifier($item);
|
||||
// // 构建策略数组
|
||||
// $policy = [
|
||||
// $roleId, // sub
|
||||
// $domain, // dom
|
||||
// $path, // obj
|
||||
// '*', // act(根据你的业务需求调整)
|
||||
// $item['methods'], // method
|
||||
// PolicyPrefix::MENU->value . $item['id'], // trace_id 资源归属用于追踪
|
||||
// ];
|
||||
// // 保留完整的策略数组
|
||||
// $incomingPolicies[] = $policy;
|
||||
// }
|
||||
// // 更新 Casbin 权限
|
||||
// $policyService->updateCasbinPolicies($currentPermissions, $incomingPolicies);
|
||||
// // 同步 Casbin 权限,确保只有授权角色保留
|
||||
// $model->casbin()->sync([$roleId]);
|
||||
// });
|
||||
// } catch (\Throwable $e) {
|
||||
// // 捕获异常并抛出自定义异常
|
||||
// throw new AdminException($e->getMessage());
|
||||
// }
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
* @param $id
|
||||
* @param $data
|
||||
*
|
||||
* @return void
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function update($id, $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$model = $this->dao->get($id);
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
|
||||
PropertyCopier::copyProperties((object)$data, $model);
|
||||
$model->save();
|
||||
|
||||
// 检查 permissions 是否存在且不为 null(未定义或显式 null 时不处理权限)
|
||||
$handlePermissions = isset($data['permissions']) && $data['permissions'] !== null;
|
||||
if ($handlePermissions) {
|
||||
$menus = $data['permissions']; // 获取权限列表
|
||||
|
||||
// 同步角色菜单关联
|
||||
$model->menus()->sync($menus);
|
||||
|
||||
// 清理相关用户的权限缓存
|
||||
$this->clearUserPermissionCacheByRole($model->id);
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// 捕获异常并抛出自定义异常
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
*
|
||||
* @param array $id
|
||||
* @param bool $force
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function destroy(array $id, bool $force = false): mixed
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($id, $force) {
|
||||
$data = Arr::normalize($id);
|
||||
$deletedIds = [];
|
||||
|
||||
foreach ($data as $id) {
|
||||
$item = $this->get($id);
|
||||
if ($item) {
|
||||
$primaryKey = $item->getPk();
|
||||
|
||||
// 清理相关用户的权限缓存
|
||||
$this->clearUserPermissionCacheByRole($id);
|
||||
|
||||
$item->delete();
|
||||
$deletedIds[] = $item->{$primaryKey};
|
||||
}
|
||||
}
|
||||
return $deletedIds;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配数据权限
|
||||
*
|
||||
* @param $id
|
||||
* @param $data
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function updateScope($id, $data): void
|
||||
{
|
||||
try {
|
||||
$this->transaction(function () use ($id, $data) {
|
||||
$scope = $data['scopes'] ?? [];
|
||||
$model = $this->dao->get($id);
|
||||
PropertyCopier::copyProperties((object)$data, $model);
|
||||
if (!empty($model)) {
|
||||
$model->save();
|
||||
if ($model->data_scope !== 2) {
|
||||
$scope = [];
|
||||
}
|
||||
$model->scopes()->sync($scope);
|
||||
}
|
||||
$this->cacheDriver()->delete(self::CACHE_ALL_ROLES_DATA);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理角色的用户权限缓存
|
||||
*
|
||||
* @param int|string $roleId
|
||||
*/
|
||||
private function clearUserPermissionCacheByRole(int|string $roleId): void
|
||||
{
|
||||
// 获取拥有此角色的所有用户
|
||||
/** @var AdminRoleService $adminRoleService */
|
||||
$adminRoleService = Container::make(AdminRoleService::class);
|
||||
$userIds = $adminRoleService->getUserIdsByRoleId($roleId);
|
||||
|
||||
if (!empty($userIds)) {
|
||||
// 清理每个用户的权限缓存
|
||||
foreach ($userIds as $userId) {
|
||||
$cacheKey = 'user_permissions_' . $userId;
|
||||
$this->cacheDriver()->delete($cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\service\core\metadata\MetadataCollectorService;
|
||||
use core\base\BaseService;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 规则扫描服务
|
||||
* 使用 MetadataCollectorService 扫描控制器,提供缓存机制
|
||||
*/
|
||||
class RuleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 缓存键
|
||||
*/
|
||||
const CACHE_KEY = 'rule_data';
|
||||
const CACHE_TTL = 300; // 缓存5分钟
|
||||
|
||||
/**
|
||||
* 获取扫描的权限列表(带缓存)
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->cacheDriver()->remember(
|
||||
self::CACHE_KEY,
|
||||
function () {
|
||||
$collector = Container::get(MetadataCollectorService::class);
|
||||
return $collector->collect();
|
||||
},
|
||||
self::CACHE_TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类树(基于 tags)
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getCategories(): array
|
||||
{
|
||||
$permissions = $this->getPermissions();
|
||||
$categories = [];
|
||||
$categoryMap = [];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
// 确保 tags 是字符串数组
|
||||
$tags = $permission['tags'] ?? ['未分类'];
|
||||
if (!is_array($tags)) {
|
||||
$tags = [$tags];
|
||||
}
|
||||
// 确保数组中的每个元素都是字符串
|
||||
$tags = array_map(function($tag) {
|
||||
if (is_string($tag)) {
|
||||
return $tag;
|
||||
}
|
||||
if (is_object($tag) && isset($tag->name)) {
|
||||
return $tag->name;
|
||||
}
|
||||
return (string) $tag;
|
||||
}, $tags);
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
if (!isset($categoryMap[$tag])) {
|
||||
$categoryId = md5($tag);
|
||||
$categoryMap[$tag] = $categoryId;
|
||||
$categories[] = [
|
||||
'id' => $categoryId,
|
||||
'pid' => 0,
|
||||
'name' => $tag,
|
||||
'value' => $categoryId,
|
||||
'label' => $tag,
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类获取接口列表
|
||||
*
|
||||
* @param string|null $categoryId
|
||||
* @param string|null $keyword
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getRoutesByCategory(?string $categoryId = null, ?string $keyword = null): array
|
||||
{
|
||||
$permissions = $this->getPermissions();
|
||||
$routes = [];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
// 确保 tags 是字符串数组
|
||||
$tags = $permission['tags'] ?? ['未分类'];
|
||||
if (!is_array($tags)) {
|
||||
$tags = [$tags];
|
||||
}
|
||||
// 确保数组中的每个元素都是字符串
|
||||
$tags = array_map(function($tag) {
|
||||
if (is_string($tag)) {
|
||||
return $tag;
|
||||
}
|
||||
if (is_object($tag) && isset($tag->name)) {
|
||||
return $tag->name;
|
||||
}
|
||||
return (string) $tag;
|
||||
}, $tags);
|
||||
|
||||
// 过滤分类
|
||||
if ($categoryId !== null) {
|
||||
$categoryIds = array_map(fn($tag) => md5($tag), $tags);
|
||||
if (!in_array($categoryId, $categoryIds)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤关键词
|
||||
if ($keyword !== null && !empty($keyword)) {
|
||||
// 构建搜索文本,只使用字符串类型的字段
|
||||
$searchParts = [];
|
||||
|
||||
// 添加 tags
|
||||
$searchParts[] = implode(' ', $tags);
|
||||
|
||||
// 添加其他字段(确保是字符串)
|
||||
$fields = ['summary', 'description', 'controller', 'method', 'code', 'route'];
|
||||
foreach ($fields as $field) {
|
||||
if (isset($permission[$field]) && is_string($permission[$field])) {
|
||||
$searchParts[] = $permission[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$searchText = implode(' ', $searchParts);
|
||||
|
||||
if (stripos($searchText, $keyword) === false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$routes[] = [
|
||||
'id' => md5($permission['route'] . '_' . $permission['method']),
|
||||
'name' => $permission['summary'] ?? $permission['description'] ?? $permission['code'],
|
||||
'method' => $this->extractHttpMethod($permission['method']),
|
||||
'path' => $permission['route'],
|
||||
'code' => $permission['code'],
|
||||
'controller' => $permission['controller'],
|
||||
'action' => $permission['method'],
|
||||
'description' => $permission['description'] ?? '',
|
||||
'tags' => $tags,
|
||||
];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从方法名中提取 HTTP 方法
|
||||
*
|
||||
* @param string $methodName
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function extractHttpMethod(string $methodName): string
|
||||
{
|
||||
$prefixes = ['get', 'post', 'put', 'delete', 'patch'];
|
||||
$lowerMethod = strtolower($methodName);
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($lowerMethod, $prefix)) {
|
||||
return strtoupper($prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return 'GET';
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->cacheDriver()->delete(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发扫描并刷新缓存
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function refresh(): array
|
||||
{
|
||||
$this->clearCache();
|
||||
return $this->getPermissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\service\admin\system;
|
||||
|
||||
use app\dao\system\UploadDao;
|
||||
use core\base\BaseService;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\upload\UploadFile;
|
||||
use madong\helper\Arr;
|
||||
use support\Container;
|
||||
|
||||
class UploadService extends BaseService
|
||||
{
|
||||
|
||||
|
||||
public function __construct(UploadDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下载图片到本地
|
||||
*
|
||||
* @param string $url
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function saveNetworkImage(string $url): array
|
||||
{
|
||||
$config = UploadFile::config('local');
|
||||
$data = file_get_contents($url);
|
||||
if ($data === false) {
|
||||
throw new AdminException('获取文件资源失败');
|
||||
}
|
||||
$image_resource = imagecreatefromstring($data);
|
||||
if (!$image_resource) {
|
||||
throw new AdminException('创建图片资源失败');
|
||||
}
|
||||
$filename = basename($url);
|
||||
$file_extension = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$full_dir = runtime_path() . '/resource/';
|
||||
if (!is_dir($full_dir)) {
|
||||
mkdir($full_dir, 0777, true);
|
||||
}
|
||||
$save_path = $full_dir . $filename;
|
||||
$content_type = 'image/';
|
||||
switch ($file_extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
$content_type = 'image/jpeg';
|
||||
$result = imagejpeg($image_resource, $save_path);
|
||||
break;
|
||||
case 'png':
|
||||
$content_type = 'image/png';
|
||||
$result = imagepng($image_resource, $save_path);
|
||||
break;
|
||||
case 'gif':
|
||||
$content_type = 'image/gif';
|
||||
$result = imagegif($image_resource, $save_path);
|
||||
break;
|
||||
default:
|
||||
imagedestroy($image_resource);
|
||||
throw new AdminException('文件格式错误');
|
||||
}
|
||||
imagedestroy($image_resource);
|
||||
if (!$result) {
|
||||
throw new AdminException('文件保存失败');
|
||||
}
|
||||
$hash = md5_file($save_path);
|
||||
$size = filesize($save_path);
|
||||
|
||||
$result = $this->dao->get(['hash' => $hash]);
|
||||
if (!empty($result)) {
|
||||
unlink($save_path);
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
/** @var ConfigService $systemConfigService */
|
||||
$systemConfigService = Container::get(ConfigService::class);
|
||||
$local = $systemConfigService->dao->get(['code' => 'local']);
|
||||
if (empty($local)) {
|
||||
throw new AdminException('缺少本地上传配置信息');
|
||||
}
|
||||
$root = Arr::fetchConfigValue($config, 'root');
|
||||
$folder = date('Ymd');
|
||||
$full_dir = base_path() . DIRECTORY_SEPARATOR . $root . $folder . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($full_dir)) {
|
||||
mkdir($full_dir, 0777, true);
|
||||
}
|
||||
$object_name = bin2hex(pack('Nn', time(), random_int(1, 65535))) . ".$file_extension";
|
||||
$newPath = $full_dir . $object_name;
|
||||
|
||||
copy($save_path, $newPath);
|
||||
unlink($save_path);
|
||||
$domain = Arr::fetchConfigValue($config, 'domain');
|
||||
$dirname = Arr::fetchConfigValue($config, 'dirname');
|
||||
$baseUrl = $dirname . $folder . '/';
|
||||
|
||||
$info['platform'] = 'local';
|
||||
$info['original_filename'] = $filename;
|
||||
$info['filename'] = $object_name;
|
||||
$info['hash'] = $hash;
|
||||
$info['content_type'] = $content_type;
|
||||
$info['base_path'] = $root . $folder . '/' . $object_name;
|
||||
$info['ext'] = $file_extension;
|
||||
$info['size'] = $size;
|
||||
$info['size_info'] = formatBytes($size);
|
||||
$info['url'] = $baseUrl . $object_name;//访问地
|
||||
$result = $this->dao->save($info);
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传
|
||||
*
|
||||
* @param string $upload
|
||||
* @param bool $isLocal
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function upload(string $upload = '', bool $isLocal = false): mixed
|
||||
{
|
||||
try {
|
||||
return $this->transaction(function () use ($upload, $isLocal) {
|
||||
/** @var ConfigService $systemConfigService */
|
||||
$baseConfig = UploadFile::config('upload');//获取上次配置
|
||||
if (empty($baseConfig)) {
|
||||
throw new AdminException('缺少上传配置信息');
|
||||
}
|
||||
$type = Arr::fetchConfigValue($baseConfig, 'mode') ?: 'local';//上次模式默认本地
|
||||
if ($isLocal) {
|
||||
$type = 'local';
|
||||
}
|
||||
$options = [];
|
||||
if (!empty($upload)) {
|
||||
$options['sub_dir'] = $upload;
|
||||
}
|
||||
$result = UploadFile::uploadFile($options);
|
||||
$data = $result[0];
|
||||
$hash = $data['unique_id'];
|
||||
|
||||
$url = str_replace('\\', '/', $data['url']);
|
||||
$path = str_replace('\\', '/', $data['save_path']);
|
||||
|
||||
// 检查文件是否已存在
|
||||
if ($filesInfo = $this->dao->get(['hash' => $hash])) {
|
||||
return $filesInfo;
|
||||
}
|
||||
|
||||
$inData = [
|
||||
'platform' => $type,
|
||||
'original_filename' => $data['origin_name'] ?? '',
|
||||
'filename' => $data['save_name'],
|
||||
'hash' => $hash,
|
||||
'content_type' => $data['mime_type'],
|
||||
'base_path' => $data['base_path'],
|
||||
'ext' => $data['extension'],
|
||||
'size' => $data['size'],
|
||||
'size_info' => formatBytes($data['size']),
|
||||
'url' => full_url($url),
|
||||
'path' => $path,
|
||||
];
|
||||
return $this->dao->save($inData);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\web;
|
||||
|
||||
use app\dao\web\AdvDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 广告服务类
|
||||
*/
|
||||
class AdvService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(AdvDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\web;
|
||||
|
||||
use app\dao\web\LinkDao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 友情链接服务类
|
||||
*/
|
||||
class LinkService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(LinkDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\admin\web;
|
||||
|
||||
use app\dao\web\MenuDao;
|
||||
use app\model\web\Menu;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 菜单服务类
|
||||
*/
|
||||
class MenuService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct(MenuDao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除菜单(含子菜单)
|
||||
*
|
||||
* @param array $ids 菜单ID数组
|
||||
*
|
||||
* @return array 被删除的所有菜单ID
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function batchDelete(array $ids): array
|
||||
{
|
||||
return $this->transaction(function () use ($ids) {
|
||||
$deletedIds = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
/** @var Menu $item */
|
||||
$item = $this->get($id);
|
||||
if (!$item) {
|
||||
continue;
|
||||
}
|
||||
// 删除菜单及子菜单,获取所有被删除的ID
|
||||
$result = $item->deleteWithAllChildren();
|
||||
if (is_array($result)) {
|
||||
$deletedIds = array_merge($deletedIds, $result);
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($deletedIds);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user