初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -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);
});
}
}