Initial commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl;
|
||||
|
||||
|
||||
class MzlController
|
||||
{
|
||||
|
||||
// 生成文件
|
||||
public function createFile(string $module, string $name)
|
||||
{
|
||||
if (!$module) {
|
||||
return '未获取到module值';
|
||||
}
|
||||
|
||||
if (!$name) {
|
||||
return '未获取到name值';
|
||||
}
|
||||
|
||||
$model = new \app\mzl\generate\actions\Generate();
|
||||
|
||||
$model->setModule($module);
|
||||
|
||||
$model->setName($name);
|
||||
|
||||
return $model->doGenerate();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\controller;
|
||||
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use support\exception\BusinessException;
|
||||
use app\mzl\common\traits\DataTransformer;
|
||||
use app\mzl\common\traits\ResponseTrait;
|
||||
use app\mzl\common\utils\SimpleQueryBuilder;
|
||||
|
||||
abstract class BaseController
|
||||
{
|
||||
use ResponseTrait, DataTransformer;
|
||||
|
||||
/**
|
||||
* 可配置的验证器(类名/实例/闭包)
|
||||
*/
|
||||
protected mixed $validator = null;
|
||||
|
||||
/**
|
||||
* 子类必须实现:返回对应的 Service 实例
|
||||
*
|
||||
* @return mixed Service 实例
|
||||
*/
|
||||
abstract protected function getService();
|
||||
|
||||
/**
|
||||
* 获取请求参数
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return array 请求参数数组
|
||||
*/
|
||||
protected function input(Request $request): array
|
||||
{
|
||||
return $request->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*
|
||||
* @return int|null 当前用户ID,如果未登录则返回null
|
||||
*/
|
||||
protected function getUserId(): ?int
|
||||
{
|
||||
return request()->user->user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入字段
|
||||
* 如果数据中不存在指定字段,则注入当前用户ID
|
||||
*
|
||||
* @param array $data 数据数组
|
||||
* @param string $field 字段名,默认为 'user_id'
|
||||
* @return array 注入字段后的数据数组
|
||||
*/
|
||||
protected function injectAdd(array $data, string $field = 'user_id'): array
|
||||
{
|
||||
return !isset($data[$field]) ? $data + [$field => $this->getUserId()] : $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数预处理入口,按场景分发
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $scene
|
||||
* @return array
|
||||
*/
|
||||
protected function prepareParams(Request $request, string $scene): array
|
||||
{
|
||||
// 默认所有场景都注入 user_id
|
||||
return $this->injectAdd($request->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* 后置处理单条记录:默认不变
|
||||
*
|
||||
* 子类可以重写这个方法来做额外字段加工,
|
||||
* 比如加 computed 字段、日期格式化、脱敏等等。
|
||||
*
|
||||
* @param array $item 单条记录(pruned 后的数组)
|
||||
* @return array 处理后的数组
|
||||
*/
|
||||
protected function postProcessItem(array $item, string $scene): array
|
||||
{
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 queryScenes 配置构造 fieldsMap
|
||||
*
|
||||
* @param array $config 必须包含 keys: fields, relations
|
||||
* @return array
|
||||
*/
|
||||
// protected function buildFieldsMap2(array $config): array
|
||||
// {
|
||||
// $relations = $config['relations'] ?? [];
|
||||
// $fieldsMap = [];
|
||||
|
||||
// // 根层:主表字段 + 所有一级关联名
|
||||
// $rootFields = $config['fields'] ?? [];
|
||||
// foreach (array_keys($relations) as $path) {
|
||||
// $rootFields[] = explode('.', $path, 2)[0];
|
||||
// }
|
||||
// $fieldsMap[''] = array_values(array_unique($rootFields));
|
||||
|
||||
// // 每个关联层:自己字段 + 直接子关联名
|
||||
// foreach ($relations as $path => $ownFields) {
|
||||
// $fieldsMap[$path] = $ownFields;
|
||||
// $segments = explode('.', $path);
|
||||
// $depth = count($segments);
|
||||
// if ($depth > 1) {
|
||||
// for ($i = 1; $i < $depth; $i++) {
|
||||
// $parent = implode('.', array_slice($segments, 0, $i));
|
||||
// $child = $segments[$i];
|
||||
// $fieldsMap[$parent][] = $child;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 去重每一层
|
||||
// foreach ($fieldsMap as $k => $arr) {
|
||||
// $fieldsMap[$k] = array_values(array_unique($arr));
|
||||
// }
|
||||
|
||||
// return $fieldsMap;
|
||||
// }
|
||||
// protected function buildFieldsMap(array $config): array
|
||||
// {
|
||||
// $relations = $config['relations'] ?? [];
|
||||
|
||||
// // 1) 处理主表层字段:把 "col as alias" -> alias
|
||||
// $rawFields = $config['fields'] ?? [];
|
||||
// $root = [];
|
||||
// foreach ($rawFields as $f) {
|
||||
// if (preg_match('/\s+as\s+/i', $f)) {
|
||||
// // alias 是 as 之后的部分
|
||||
// [$_, $alias] = preg_split('/\s+as\s+/i', $f);
|
||||
// $root[] = $alias;
|
||||
// } else {
|
||||
// $root[] = $f;
|
||||
// }
|
||||
// }
|
||||
// // 再把顶级关联名也加进来
|
||||
// foreach (array_keys($relations) as $path) {
|
||||
// $root[] = explode('.', $path, 2)[0];
|
||||
// }
|
||||
// $fieldsMap = ['' => array_values(array_unique($root))];
|
||||
|
||||
// // 2) 每个关联路径,自身字段 & 子关联名
|
||||
// foreach ($relations as $path => $rawCols) {
|
||||
// $arr = [];
|
||||
// // 同样把 alias 拆出来
|
||||
// foreach ($rawCols as $col) {
|
||||
// if (preg_match('/\s+as\s+/i', $col)) {
|
||||
// [, $alias] = preg_split('/\s+as\s+/i', $col);
|
||||
// $arr[] = $alias;
|
||||
// } else {
|
||||
// $arr[] = $col;
|
||||
// }
|
||||
// }
|
||||
// // 再把它的直接子关联名也加上
|
||||
// $segments = explode('.', $path);
|
||||
// $depth = count($segments);
|
||||
// if ($depth > 1) {
|
||||
// for ($i = 1; $i < $depth; $i++) {
|
||||
// $parent = implode('.', array_slice($segments, 0, $i));
|
||||
// $child = $segments[$i];
|
||||
// $fieldsMap[$parent][] = $child;
|
||||
// }
|
||||
// }
|
||||
// $fieldsMap[$path] = array_values(array_unique($arr));
|
||||
// }
|
||||
|
||||
// return $fieldsMap;
|
||||
// }
|
||||
|
||||
/**
|
||||
* 验证请求
|
||||
*
|
||||
* @param array $data 请求数据
|
||||
* @param string $scene 验证场景
|
||||
* @return array 验证后的数据
|
||||
* @throws BusinessException 验证失败时抛出业务异常
|
||||
*/
|
||||
protected function validateRequest(array $data, string $scene = ''): array
|
||||
{
|
||||
if (!$this->validator) {
|
||||
return $data;
|
||||
}
|
||||
$validator = is_callable($this->validator)
|
||||
? call_user_func($this->validator)
|
||||
: (is_string($this->validator) ? new $this->validator : $this->validator);
|
||||
|
||||
if ($scene && method_exists($validator, 'scene')) {
|
||||
$validator->scene($scene);
|
||||
}
|
||||
if (!$validator->check($data)) {
|
||||
throw new BusinessException($validator->getError());
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $scene 场景名称,默认为 'default'
|
||||
* @param callable|null $postProcessor 额外字段处理回调函数
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
protected function listWithTransform(
|
||||
array $params,
|
||||
string $scene = 'default',
|
||||
bool $paginate = true
|
||||
): Response {
|
||||
$collection = $paginate
|
||||
? $this->getService()->list($params, $scene)
|
||||
: $this->getService()->all($params, $scene);
|
||||
|
||||
$config = $this->getService()->queryScenes()[$scene]($params);
|
||||
// $fieldsMap = $this->buildFieldsMap($config);
|
||||
|
||||
// —— 如果 fields 为空,就不做任何裁剪,直接原样输出 ——
|
||||
if (empty($config['fields'])) {
|
||||
$data = $this->transformData(
|
||||
$collection,
|
||||
fn(array $item) => $this->postProcessItem($item, $scene)
|
||||
);
|
||||
return $this->successResponse($data);
|
||||
}
|
||||
|
||||
$fieldsMap = SimpleQueryBuilder::buildFieldsMap($config);
|
||||
|
||||
$result = $this->transformAndPrune(
|
||||
$collection,
|
||||
$fieldsMap,
|
||||
null,
|
||||
fn(array $item) => $this->postProcessItem($item, $scene)
|
||||
);
|
||||
|
||||
return $this->successResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源列表
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $scene 场景名称,默认为 'default'
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function index(Request $request, string $scene = 'default'): Response
|
||||
{
|
||||
return $this->listWithTransform(
|
||||
$this->prepareParams($request, $scene),
|
||||
$scene
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有资源(不分页)
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $scene 场景名称,默认为 'default'
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function all(Request $request, string $scene = 'default'): Response
|
||||
{
|
||||
return $this->listWithTransform(
|
||||
$this->prepareParams($request, $scene),
|
||||
$scene,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
public function oneWithTransform(array $params, string $scene = 'default', int $id = 0): Response
|
||||
{
|
||||
$model = $id
|
||||
? $this->getService()->getById($id, $params, $scene)
|
||||
: $this->getService()->getOne($params, $scene);
|
||||
|
||||
if (!$model) {
|
||||
return $this->errorResponse('资源不存在', 1, 404);
|
||||
}
|
||||
|
||||
$config = $this->getService()->queryScenes()[$scene]($params);
|
||||
// $fieldsMap = $this->buildFieldsMap($config);
|
||||
|
||||
// —— 如果 fields 为空,就不做任何裁剪,直接原样输出 ——
|
||||
if (empty($config['fields'])) {
|
||||
$data = $this->transformData(
|
||||
$model,
|
||||
fn(array $item) => $this->postProcessItem($item, $scene)
|
||||
);
|
||||
return $this->successResponse($data);
|
||||
}
|
||||
|
||||
$fieldsMap = SimpleQueryBuilder::buildFieldsMap($config);
|
||||
|
||||
$data = $this->transformAndPrune(
|
||||
$model,
|
||||
$fieldsMap,
|
||||
null,
|
||||
fn(array $item) => $this->postProcessItem($item, $scene)
|
||||
);
|
||||
|
||||
return $this->successResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /resource/{id}
|
||||
*/
|
||||
public function show(Request $request, int $id, string $scene = 'default'): Response
|
||||
{
|
||||
return $this->oneWithTransform(
|
||||
$this->prepareParams($request, $scene),
|
||||
$scene,
|
||||
$id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /resource/one (按任意条件取单条)
|
||||
*/
|
||||
public function one(Request $request, string $scene = 'default'): Response
|
||||
{
|
||||
return $this->oneWithTransform(
|
||||
$this->prepareParams($request, $scene),
|
||||
$scene
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新资源
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$data = $this->validateRequest($request->all(), 'store');
|
||||
$model = $this->getService()->create($data);
|
||||
return $this->successResponse($this->transformData($model));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新现有资源
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param int $id 资源ID
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function update(Request $request, int $id): Response
|
||||
{
|
||||
$data = $this->validateRequest($request->all(), 'update');
|
||||
$result = $this->getService()->update($id, $data);
|
||||
return $result
|
||||
? $this->successResponse(['id' => $id])
|
||||
: $this->errorResponse('更新失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资源
|
||||
*
|
||||
* @param int $id 资源ID
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function destroy(int $id): Response
|
||||
{
|
||||
$result = $this->getService()->delete($id);
|
||||
return $result
|
||||
? $this->successResponse(['id' => $id])
|
||||
: $this->errorResponse('删除失败');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\org;
|
||||
|
||||
|
||||
class XunfeiFanyi
|
||||
{
|
||||
|
||||
protected $error = "";
|
||||
|
||||
public function tocurl($url, $header, $content)
|
||||
{
|
||||
$ch = curl_init();
|
||||
if(substr($url,0,5)=='https'){
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 1);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
if (is_array($header)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if (!empty($content)) {
|
||||
if (is_array($content)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content));
|
||||
} else if (is_string($content)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
|
||||
}
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$error=curl_error($ch);
|
||||
//var_dump($error);
|
||||
if($error){
|
||||
die($error);
|
||||
}
|
||||
$header = curl_getinfo($ch);
|
||||
curl_close($ch);
|
||||
$data = array('header' => $header,'body' => $response);
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
public function xfyun($text = "中华人民共和国于1949年成立", $from = "cn", $to = "en")
|
||||
{
|
||||
//在控制台-我的应用-机器翻译获取
|
||||
$app_id = "3d46198c";
|
||||
//在控制台-我的应用-机器翻译获取
|
||||
$api_sec = "ZWY2MmUyNjJjYmFkOTFlYTVmYWVhMGYy";
|
||||
//在控制台-我的应用-机器翻译获取
|
||||
$api_key = "b6d805f240176ecaea85130cb5f2bca1";
|
||||
// 机器翻译接口地址
|
||||
$url = "https://ntrans.xfyun.cn/v2/ots";
|
||||
//body组装
|
||||
// $text = "中华人民共和国于1949年成立";//待翻译文本
|
||||
$body = json_encode($this->getBody($app_id, $from, $to, $text));
|
||||
|
||||
// 组装http请求头
|
||||
$date =gmdate('D, d M Y H:i:s') . ' GMT';
|
||||
|
||||
$digestBase64 = "SHA-256=".base64_encode(hash("sha256", $body, true));
|
||||
$builder = sprintf("host: %s
|
||||
date: %s
|
||||
POST /v2/ots HTTP/1.1
|
||||
digest: %s", "ntrans.xfyun.cn", $date, $digestBase64);
|
||||
// echo($builder);
|
||||
$sha = base64_encode(hash_hmac("sha256", $builder, $api_sec, true));
|
||||
|
||||
$authorization = sprintf("api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", $api_key,"hmac-sha256",
|
||||
"host date request-line digest", $sha);
|
||||
|
||||
$header = [
|
||||
"Authorization: ".$authorization,
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json,version=1.0',
|
||||
'Host: ntrans.xfyun.cn',
|
||||
'Date: ' .$date,
|
||||
'Digest: '.$digestBase64
|
||||
];
|
||||
$response = $this->tocurl($url, $header, $body);
|
||||
|
||||
// var_dump($response['body']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
public function getBody($app_id, $from, $to, $text)
|
||||
{
|
||||
$common_param = [
|
||||
'app_id' => $app_id
|
||||
];
|
||||
|
||||
$business = [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
$data = [
|
||||
"text" => base64_encode($text)
|
||||
];
|
||||
|
||||
return $body = [
|
||||
'common' => $common_param,
|
||||
'business' => $business,
|
||||
'data' => $data
|
||||
];
|
||||
}
|
||||
|
||||
// 翻译
|
||||
public function fanyi($text = "", $from = "", $to = "")
|
||||
{
|
||||
try {
|
||||
$res = $this->xfyun($text, $from, $to);
|
||||
$data = json_decode($res["body"], true);
|
||||
if ($data["code"] === 0) {
|
||||
return $data["data"]["result"];
|
||||
} else {
|
||||
$this->error = $data["message"];
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable $th) {
|
||||
$this->error = $th->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 异常
|
||||
public function error()
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\services;
|
||||
|
||||
use app\mzl\common\utils\SimpleQueryBuilder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 通用基础服务类 BaseService
|
||||
*/
|
||||
abstract class BaseService
|
||||
{
|
||||
/**
|
||||
* Eloquent 模型实例
|
||||
* @var Model
|
||||
*/
|
||||
protected Model $model;
|
||||
|
||||
/**
|
||||
* 允许的字段列表
|
||||
*/
|
||||
protected $allowedFields = [];
|
||||
|
||||
// 声明需要多语言翻译
|
||||
protected array $translatable = [];
|
||||
|
||||
/**
|
||||
* 从$data中提取出源文本字段,并从$data里删除它们
|
||||
*/
|
||||
protected function extractSourceFields(array &$data): array
|
||||
{
|
||||
$source = [];
|
||||
foreach ($this->translatable as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$source[$field] = $data[$field];
|
||||
unset($data[$field]);
|
||||
}
|
||||
}
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子类必须实现:返回模型类名
|
||||
*
|
||||
* @return string 模型类名
|
||||
*/
|
||||
abstract public function getModel(): string;
|
||||
|
||||
/**
|
||||
* 构造函数,初始化模型实例
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->model = new ($this->getModel());
|
||||
}
|
||||
|
||||
/**
|
||||
* 子类定义:场景化查询配置
|
||||
* 返回一个关联数组,键为场景名称,值为查询配置数组或闭包
|
||||
*
|
||||
* @return array<string, array|\Closure> 查询配置数组或闭包
|
||||
*/
|
||||
public function queryScenes(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据场景和请求参数,取到完整的 Builder 配置
|
||||
*
|
||||
* @param array $params 请求参数
|
||||
* @param string $scene 场景名称
|
||||
* @return array 查询配置数组
|
||||
*/
|
||||
protected function getQueryConfig(array $params, string $scene): array
|
||||
{
|
||||
$scenes = $this->queryScenes();
|
||||
$cfg = $scenes[$scene] ?? [];
|
||||
return is_callable($cfg) ? $cfg($params) : $cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造Builder
|
||||
*/
|
||||
protected function createQueryBuilder(array $params, string $scene): SimpleQueryBuilder
|
||||
{
|
||||
$config = $this->getQueryConfig($params, $scene);
|
||||
return (new SimpleQueryBuilder($this->model))->applyQueryConfig($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页列表
|
||||
*
|
||||
* @param array $params 请求参数
|
||||
* @param string $scene 场景名称,默认为 'default'
|
||||
* @return LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function list(array $params = [], string $scene = 'default'): LengthAwarePaginator
|
||||
{
|
||||
return $this->createQueryBuilder($params, $scene)->paginate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 不分页列表
|
||||
*
|
||||
* @param array $params 请求参数
|
||||
* @param string $scene 场景名称,默认为 'default'
|
||||
* @return Collection 查询结果集合
|
||||
*/
|
||||
public function all(array $params = [], string $scene = 'default'): Collection
|
||||
{
|
||||
return $this->createQueryBuilder($params, $scene)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义传入配置查询
|
||||
*
|
||||
* @param array $config 查询配置
|
||||
* @param bool $paginate 是否分页,默认为 true
|
||||
* @return LengthAwarePaginator|Collection 查询结果,分页或集合
|
||||
*/
|
||||
public function query(array $config, bool $paginate = true): LengthAwarePaginator|Collection
|
||||
{
|
||||
$builder = new SimpleQueryBuilder($this->model);
|
||||
$builder->applyQueryConfig($config);
|
||||
return $paginate ? $builder->paginate() : $builder->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键取一条(带关联)
|
||||
*/
|
||||
public function getById(int $id, array $params = [], string $scene = 'default'): ?Model
|
||||
{
|
||||
return $this->createQueryBuilder($params, $scene)
|
||||
->build()
|
||||
->whereKey($id)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按过滤条件取一条(带关联,不限主键)
|
||||
*/
|
||||
public function getOne(array $params = [], string $scene = 'default'): ?Model
|
||||
{
|
||||
return $this->createQueryBuilder($params, $scene)
|
||||
->build()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找单个模型实例
|
||||
*
|
||||
* @param int $id 模型ID
|
||||
* @return Model|null 模型实例或null
|
||||
*/
|
||||
public function find(int $id): ?Model
|
||||
{
|
||||
return $this->model->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新模型实例
|
||||
*
|
||||
* @param array $data 创建数据
|
||||
* @return Model 新创建的模型实例
|
||||
*/
|
||||
public function create(array $data): Model
|
||||
{
|
||||
return $this->model->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新现有模型实例
|
||||
*
|
||||
* @param int $id 模型ID
|
||||
* @param array $data 更新数据
|
||||
* @return bool 更新是否成功
|
||||
*/
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$record = $this->find($id);
|
||||
return $record ? $record->update($data) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型实例
|
||||
*
|
||||
* @param int $id 模型ID
|
||||
* @return bool 删除是否成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$record = $this->find($id);
|
||||
return $record ? $record->delete() : false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\traits;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
trait DataTransformer
|
||||
{
|
||||
/**
|
||||
* 核心方法:对任意数据进行转换并返回数组
|
||||
*
|
||||
* @param mixed $rawData 原始数据,可为 Paginator / Model / Collection / array 等
|
||||
* @param callable|null $postProcessor 可选的后置处理回调,接受处理完的数组再返回新的数组
|
||||
* @param callable|null $fieldFormatter 可选的字段命名转换回调,如 snake_case
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function transformData(
|
||||
mixed $rawData,
|
||||
?callable $postProcessor = null,
|
||||
?callable $fieldFormatter = null
|
||||
): array {
|
||||
try {
|
||||
$fieldFormatter = $fieldFormatter ?: fn($field) => $field;
|
||||
|
||||
if ($rawData instanceof LengthAwarePaginator) {
|
||||
$formatted = $this->formatPaginated($rawData, $postProcessor, $fieldFormatter);
|
||||
} elseif ($rawData instanceof Model) {
|
||||
$formatted = $this->formatModel($rawData, $fieldFormatter);
|
||||
} elseif ($rawData instanceof Collection) {
|
||||
$formatted = $this->formatCollection($rawData, $fieldFormatter);
|
||||
} elseif (is_array($rawData)) {
|
||||
$formatted = $this->formatArray($rawData, $fieldFormatter);
|
||||
} else {
|
||||
$formatted = (array) $rawData;
|
||||
}
|
||||
|
||||
if (is_callable($postProcessor)) {
|
||||
$formatted = $postProcessor($formatted);
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分页数据:将每条记录也进行 transformData 递归处理
|
||||
*
|
||||
* @param LengthAwarePaginator $paginator
|
||||
* @param callable|null $postProcessor
|
||||
* @param callable $fieldFormatter
|
||||
* @return array
|
||||
*/
|
||||
protected function formatPaginated(
|
||||
LengthAwarePaginator $paginator,
|
||||
?callable $postProcessor,
|
||||
callable $fieldFormatter
|
||||
): array {
|
||||
$list = $paginator->getCollection()->map(function ($item) use ($postProcessor, $fieldFormatter) {
|
||||
return $this->transformData($item, $postProcessor, $fieldFormatter);
|
||||
})->toArray();
|
||||
|
||||
$meta = [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'first_page_url' => $paginator->url(1),
|
||||
'last_page_url' => $paginator->url($paginator->lastPage()),
|
||||
'next_page_url' => $paginator->nextPageUrl(),
|
||||
'prev_page_url' => $paginator->previousPageUrl(),
|
||||
];
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 Eloquent 模型:递归嵌套关联
|
||||
*
|
||||
* @param Model $model
|
||||
* @param callable $fieldFormatter
|
||||
* @return array
|
||||
*/
|
||||
protected function formatModel(Model $model, callable $fieldFormatter): array
|
||||
{
|
||||
$formatted = [];
|
||||
|
||||
// 主表字段
|
||||
foreach ($model->getAttributes() as $key => $value) {
|
||||
$formatted[$fieldFormatter($key)] = $value;
|
||||
}
|
||||
|
||||
// 关联数据,递归嵌套
|
||||
foreach ($model->getRelations() as $relation => $relationData) {
|
||||
if ($relationData instanceof Model) {
|
||||
// 单个关联模型
|
||||
$formatted[$relation] = $this->formatModel($relationData, $fieldFormatter);
|
||||
} elseif ($relationData instanceof Collection) {
|
||||
// 关联集合
|
||||
$formatted[$relation] = $relationData
|
||||
->map(fn($item) => $this->transformData($item, null, $fieldFormatter))
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化集合
|
||||
*
|
||||
* @param Collection $collection
|
||||
* @param callable $fieldFormatter
|
||||
* @return array
|
||||
*/
|
||||
protected function formatCollection(Collection $collection, callable $fieldFormatter): array
|
||||
{
|
||||
return $collection->map(fn($item) => $this->transformData($item, null, $fieldFormatter))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化普通数组
|
||||
*
|
||||
* @param array $array
|
||||
* @param callable $fieldFormatter
|
||||
* @return array
|
||||
*/
|
||||
protected function formatArray(array $array, callable $fieldFormatter): array
|
||||
{
|
||||
return array_map(fn($item) => $this->transformData($item, null, $fieldFormatter), $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归裁剪数组,只保留指定字段
|
||||
*
|
||||
* @param array $data 当前要裁剪的数据(可能是单条关联,也可能是 list)
|
||||
* @param array $fieldsMap 白名单映射,key=>[allowed fields]
|
||||
* @param string $path 当前路径('' / 'items' / 'items.sub' …)
|
||||
* @return array
|
||||
*/
|
||||
protected function pruneFields(array $data, array $fieldsMap, string $path = ''): array
|
||||
{
|
||||
// —— 新增:如果这是一个纯列表(list),逐项递归 prune 并返回
|
||||
if ($this->isListArray($data)) {
|
||||
return array_map(
|
||||
fn($item) => is_array($item)
|
||||
? $this->pruneFields($item, $fieldsMap, $path)
|
||||
: $item,
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
// 如果当前层没有配置白名单,原样返回(或也可以 return [] 视业务)
|
||||
if (! isset($fieldsMap[$path])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$allowed = $fieldsMap[$path];
|
||||
$out = [];
|
||||
|
||||
foreach ($allowed as $key) {
|
||||
if (! array_key_exists($key, $data)) {
|
||||
continue;
|
||||
}
|
||||
$val = $data[$key];
|
||||
|
||||
// 如果下一层还是个数组,继续递归
|
||||
if (is_array($val)) {
|
||||
$nextPath = ltrim($path . '.' . $key, '.');
|
||||
$out[$key] = $this->pruneFields($val, $fieldsMap, $nextPath);
|
||||
} else {
|
||||
$out[$key] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 Paginator/Model/Collection/array 统一格式化,并按 fieldsMap 裁剪
|
||||
*
|
||||
* @param mixed $rawData
|
||||
* @param array|null $fieldsMap 顶层 '' + 各关联路径的白名单
|
||||
* @param callable|null $fieldFormatter 字段格式化回调
|
||||
* @param callable|null $postProcessor 单条记录后置处理回调
|
||||
* @return array
|
||||
*/
|
||||
protected function transformAndPrune(
|
||||
mixed $rawData,
|
||||
?array $fieldsMap = null,
|
||||
?callable $fieldFormatter = null,
|
||||
?callable $postProcessor = null
|
||||
): array {
|
||||
// 1) 统一转换成数组
|
||||
$data = $this->transformData($rawData, null, $fieldFormatter);
|
||||
|
||||
// 2) 如果没有白名单配置,直接返回原始数据
|
||||
if (empty($fieldsMap) || !isset($fieldsMap[''])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// 3) 分页结构:['list'=>[...],'meta'=>[...]]
|
||||
if (isset($data['list'], $data['meta'])) {
|
||||
$data['list'] = array_map(
|
||||
fn($item) => $this->processItem($item, $fieldsMap, $postProcessor),
|
||||
$data['list']
|
||||
);
|
||||
return $data;
|
||||
}
|
||||
|
||||
// 4) 数字索引数组 => 当作“非分页列表”处理
|
||||
if ($this->isListArray($data)) {
|
||||
return array_map(
|
||||
fn($item) => $this->processItem($item, $fieldsMap, $postProcessor),
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
// 5) 单条记录
|
||||
return $this->processItem($data, $fieldsMap, $postProcessor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪并做后置处理
|
||||
*/
|
||||
private function processItem(
|
||||
array $item,
|
||||
array $fieldsMap,
|
||||
?callable $postProcessor
|
||||
): array {
|
||||
$pruned = $this->pruneFields($item, $fieldsMap);
|
||||
return $postProcessor ? $postProcessor($pruned) : $pruned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个数组是不是“索引从 0 开始的列表”
|
||||
*/
|
||||
private function isListArray(array $arr): bool
|
||||
{
|
||||
if (empty($arr)) {
|
||||
return true;
|
||||
}
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\traits;
|
||||
|
||||
use support\Response;
|
||||
|
||||
trait ResponseTrait
|
||||
{
|
||||
/**
|
||||
* 返回成功响应
|
||||
*
|
||||
* @param mixed $data 响应数据
|
||||
* @param string $message 响应消息,默认“请求成功”
|
||||
* @param int $code 内部状态码,默认 0 表示成功
|
||||
* @param int $httpStatus HTTP 状态码,默认 200
|
||||
* @param array $header 响应头,默认 JSON 格式
|
||||
* @return Response
|
||||
*/
|
||||
public function successResponse(
|
||||
$data = [],
|
||||
string $message = '请求成功',
|
||||
int $code = 0,
|
||||
int $httpStatus = 200,
|
||||
array $header = ['Content-Type' => 'application/json']
|
||||
): Response {
|
||||
return $this->result('success', $code, $httpStatus, $message, $data, $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回错误响应
|
||||
*
|
||||
* @param string $message 响应错误消息,默认“请求失败”
|
||||
* @param int $code 内部状态码,默认 1 表示错误
|
||||
* @param int $httpStatus HTTP 状态码,默认 400
|
||||
* @param mixed $data 可选的错误数据
|
||||
* @param array $header 响应头,默认 JSON 格式
|
||||
* @return Response
|
||||
*/
|
||||
public function errorResponse(
|
||||
string $message = '请求失败',
|
||||
int $code = 1,
|
||||
int $httpStatus = 400,
|
||||
$data = [],
|
||||
array $header = ['Content-Type' => 'application/json']
|
||||
): Response {
|
||||
return $this->result('error', $code, $httpStatus, $message, $data, $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一响应格式方法
|
||||
*
|
||||
* @param string $status 响应状态,例如 "success" 或 "error"
|
||||
* @param int $code 内部状态码
|
||||
* @param int $httpStatus HTTP 状态码
|
||||
* @param string $message 响应消息
|
||||
* @param mixed $data 响应数据
|
||||
* @param array $header 响应头
|
||||
* @return Response
|
||||
*/
|
||||
protected function result(
|
||||
string $status,
|
||||
int $code,
|
||||
int $httpStatus,
|
||||
string $message,
|
||||
$data,
|
||||
array $header
|
||||
): Response {
|
||||
$body = [
|
||||
'status' => $status,
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), $httpStatus, $header);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\traits;
|
||||
|
||||
/**
|
||||
* 调用服务类的便捷工具
|
||||
*/
|
||||
trait ServiceCallTrait
|
||||
{
|
||||
/**
|
||||
* 实例缓存
|
||||
*/
|
||||
protected array $serviceCache = [];
|
||||
|
||||
/**
|
||||
* 调用指定 Service
|
||||
*
|
||||
* @param string $serviceClass 服务类名(必须继承 BaseService)
|
||||
* @return object
|
||||
*/
|
||||
protected function callService(string $serviceClass): object
|
||||
{
|
||||
if (!isset($this->serviceCache[$serviceClass])) {
|
||||
$this->serviceCache[$serviceClass] = new $serviceClass();
|
||||
}
|
||||
return $this->serviceCache[$serviceClass];
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷调用某服务下某个方法
|
||||
*
|
||||
* @param string $serviceClass
|
||||
* @param string $method
|
||||
* @param array $args
|
||||
* @return mixed
|
||||
*/
|
||||
protected function callServiceMethod(string $serviceClass, string $method, array $args = []): mixed
|
||||
{
|
||||
$service = $this->callService($serviceClass);
|
||||
|
||||
if (!method_exists($service, $method)) {
|
||||
throw new \BadMethodCallException("Method {$method} does not exist in {$serviceClass}");
|
||||
}
|
||||
|
||||
return call_user_func_array([$service, $method], $args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\utils;
|
||||
|
||||
class SearchHelper
|
||||
{
|
||||
public static function parseFiltersAndRelations(array $params, array $mapping): array
|
||||
{
|
||||
$filters = [];
|
||||
$relations = [];
|
||||
$hasRelations = [];
|
||||
|
||||
// 解析过滤条件和关联字段
|
||||
foreach ($mapping as $paramKey => $definition) {
|
||||
$field = $definition['field'];
|
||||
$operator = $definition['operator'];
|
||||
$value = $params[$paramKey] ?? null;
|
||||
|
||||
if (self::isValidValue($value, $operator)) {
|
||||
$filters[] = compact('field', 'operator', 'value');
|
||||
if (str_contains($field, '.')) {
|
||||
$relations[] = self::extractRelationPath($field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 whereHas 关联字段(如 profile.age)
|
||||
foreach ($params as $key => $value) {
|
||||
if (str_contains($key, '.')) {
|
||||
$relation = explode('.', $key)[0]; // 获取关联名
|
||||
$hasRelations[$relation] = function ($query) use ($key, $value) {
|
||||
$query->where($key, '=', $value);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'filters' => $filters,
|
||||
'relations' => array_unique($relations),
|
||||
'hasRelations' => $hasRelations,
|
||||
];
|
||||
}
|
||||
|
||||
private static function isValidValue(mixed $value, string $operator): bool
|
||||
{
|
||||
$operator = strtolower($operator);
|
||||
|
||||
if (in_array($operator, ['in', 'not_in', 'between'])) {
|
||||
return is_array($value) && !empty($value);
|
||||
}
|
||||
|
||||
if (in_array($operator, ['null', 'not_null'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
private static function extractRelationPath(string $field): string
|
||||
{
|
||||
$parts = explode('.', $field);
|
||||
array_pop($parts);
|
||||
return implode('.', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\utils;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 通用查询构建器 SimpleQueryBuilder
|
||||
*/
|
||||
class SimpleQueryBuilder
|
||||
{
|
||||
protected ?Model $model = null;
|
||||
protected ?Builder $query = null;
|
||||
|
||||
protected array $filters = [];
|
||||
protected array $sorts = [];
|
||||
protected array $fields = [];
|
||||
protected array $relations = [];
|
||||
protected array $hasRelations = [];
|
||||
protected array $pagination = [
|
||||
'page' => 1,
|
||||
'per_page' => 10,
|
||||
];
|
||||
|
||||
/** @var string[] 要执行 withCount 的关联 */
|
||||
protected array $counts = [];
|
||||
|
||||
public array $allowedFields = [];
|
||||
|
||||
protected ?\Closure $customQueryCallback = null;
|
||||
|
||||
public function __construct(?Model $model = null)
|
||||
{
|
||||
if ($model) {
|
||||
$this->setModel($model);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模型
|
||||
*/
|
||||
public function setModel(Model $model): self
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->query = $model->newQuery();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量应用查询配置
|
||||
*
|
||||
* @param array $config
|
||||
* @return $this
|
||||
*/
|
||||
public function applyQueryConfig(array $config): self
|
||||
{
|
||||
foreach ($config['filters'] ?? [] as $filter) {
|
||||
$this->addFilter($filter['field'], $filter['operator'], $filter['value']);
|
||||
}
|
||||
|
||||
foreach ($config['sorts'] ?? [] as $sort) {
|
||||
$this->addSort($sort['field'], $sort['direction'] ?? 'asc');
|
||||
}
|
||||
|
||||
if (!empty($config['fields'])) {
|
||||
$this->selectFields($config['fields']);
|
||||
}
|
||||
|
||||
foreach ($config['relations'] ?? [] as $relation => $fields) {
|
||||
$this->withRelation($relation, $fields);
|
||||
}
|
||||
|
||||
foreach ($config['hasRelations'] ?? [] as $relation => $callback) {
|
||||
if ($callback instanceof \Closure) {
|
||||
$this->addHasRelation($relation, $callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:处理 counts
|
||||
foreach ($config['counts'] ?? [] as $relation) {
|
||||
$this->addCount($relation);
|
||||
}
|
||||
|
||||
if (!empty($config['pagination'])) {
|
||||
$this->setPagination(
|
||||
$config['pagination']['page'] ?? 1,
|
||||
$config['pagination']['per_page'] ?? 15
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($config['custom']) && $config['custom'] instanceof \Closure) {
|
||||
$this->withCustomQuery($config['custom']);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单个过滤条件
|
||||
*/
|
||||
public function addFilter(string $field, string $operator, mixed $value): self
|
||||
{
|
||||
$this->filters[] = compact('field', 'operator', 'value');
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加排序字段
|
||||
*/
|
||||
public function addSort(string $field, string $direction = 'asc'): self
|
||||
{
|
||||
$this->sorts[$field] = strtolower($direction) === 'desc' ? 'desc' : 'asc';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置查询字段(select),可配合 allowedFields 校验
|
||||
*/
|
||||
public function selectFields(array $fields): self
|
||||
{
|
||||
$this->fields = array_unique($fields);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载关联(支持指定字段)
|
||||
*/
|
||||
public function withRelation2(string $relation, array $fields = []): self
|
||||
{
|
||||
$this->relations[$relation] = !empty($fields) ? $fields : '*';
|
||||
return $this;
|
||||
}
|
||||
/**
|
||||
* 加载关联(支持指定字段 或 自定义闭包)
|
||||
*
|
||||
* @param string $relation 关联名,支持点式多级
|
||||
* @param array|\Closure $fields 字段数组 或 查询闭包
|
||||
*/
|
||||
public function withRelation(string $relation, $fields = []): self
|
||||
{
|
||||
if ($fields instanceof \Closure) {
|
||||
// 自定义关联查询闭包
|
||||
$this->relations[$relation] = $fields;
|
||||
} else {
|
||||
// 字段选择,空数组表示全部
|
||||
$this->relations[$relation] = !empty($fields) ? $fields : '*';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加 whereHas 条件
|
||||
*/
|
||||
public function addHasRelation(string $relation, \Closure $callback): self
|
||||
{
|
||||
$this->hasRelations[] = compact('relation', 'callback');
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置分页
|
||||
*/
|
||||
public function setPagination(int $page, int $perPage): self
|
||||
{
|
||||
$this->pagination = [
|
||||
'page' => max(1, $page),
|
||||
'per_page' => min(max(1, $perPage), 100),
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入复杂查询闭包(适合业务层扩展)
|
||||
*/
|
||||
public function withCustomQuery(\Closure $callback): self
|
||||
{
|
||||
$this->customQueryCallback = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加关联数量统计 (withCount)
|
||||
*
|
||||
* @param string $relation 关联名,支持点式多层,如 "orders.items"
|
||||
* @return $this
|
||||
*/
|
||||
public function addCount(string $relation): self
|
||||
{
|
||||
$this->counts[] = $relation;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整查询(不执行)
|
||||
*/
|
||||
public function build(): Builder
|
||||
{
|
||||
if (!$this->query && $this->model) {
|
||||
$this->query = $this->model->newQuery();
|
||||
}
|
||||
|
||||
$this->applyFilters()
|
||||
->applySorts()
|
||||
->applyFields()
|
||||
->applyRelations()
|
||||
->applyWhereHas();
|
||||
|
||||
// ========== 新增:关联计数 ==========
|
||||
if (!empty($this->counts)) {
|
||||
// Laravel 支持 withCount(['rel1','rel2'])
|
||||
$this->query->withCount($this->counts);
|
||||
}
|
||||
|
||||
if ($this->customQueryCallback) {
|
||||
call_user_func($this->customQueryCallback, $this->query);
|
||||
}
|
||||
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集合(不分页)
|
||||
*/
|
||||
public function get(): Collection
|
||||
{
|
||||
return $this->build()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分页结果
|
||||
*/
|
||||
public function paginate(): LengthAwarePaginator
|
||||
{
|
||||
return $this->build()->paginate(
|
||||
$this->pagination['per_page'],
|
||||
['*'],
|
||||
'page',
|
||||
$this->pagination['page']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用过滤
|
||||
*/
|
||||
protected function applyFilters(): self
|
||||
{
|
||||
foreach ($this->filters as $filter) {
|
||||
$field = $filter['field'];
|
||||
$operator = $filter['operator'];
|
||||
$value = $filter['value'];
|
||||
|
||||
if (str_contains($field, '.')) {
|
||||
$segments = explode('.', $field);
|
||||
$relationPath = implode('.', array_slice($segments, 0, -1));
|
||||
$lastField = end($segments);
|
||||
|
||||
$this->query->whereHas($relationPath, function (Builder $q) use ($lastField, $operator, $value) {
|
||||
$this->applyCondition($q, $lastField, $operator, $value);
|
||||
});
|
||||
} else {
|
||||
$this->applyCondition($this->query, $field, $operator, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用排序
|
||||
*/
|
||||
protected function applySorts(): self
|
||||
{
|
||||
foreach ($this->sorts as $field => $direction) {
|
||||
$this->query->orderBy($field, $direction);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用字段选择
|
||||
*/
|
||||
// protected function applyFields2(): self
|
||||
// {
|
||||
// if (!empty($this->fields)) {
|
||||
// // 排除关联字段(如 class.teacher.realname)
|
||||
// $fields = array_filter($this->fields, fn($field) => !str_contains($field, '.'));
|
||||
|
||||
// // 白名单过滤
|
||||
// $fields = !empty($this->allowedFields)
|
||||
// ? array_intersect($fields, $this->allowedFields)
|
||||
// : $fields;
|
||||
|
||||
// if (empty($fields)) {
|
||||
// throw new \InvalidArgumentException('字段选择无效');
|
||||
// }
|
||||
|
||||
// $this->query->select(array_unique($fields));
|
||||
// }
|
||||
|
||||
// return $this;
|
||||
// }
|
||||
|
||||
/**
|
||||
* 应用字段选择,支持普通列和带 alias 的 Raw 表达式
|
||||
*/
|
||||
protected function applyFields(): self
|
||||
{
|
||||
if (empty($this->fields)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
// 只保留主表字段(排除带点的关联字段)
|
||||
$cols = array_filter($this->fields, fn($f) => ! str_contains($f, '.'));
|
||||
|
||||
// 如果设置了 allowedFields,做一次白名单交集
|
||||
if (! empty($this->allowedFields)) {
|
||||
$cols = array_intersect($cols, $this->allowedFields);
|
||||
}
|
||||
|
||||
if (empty($cols)) {
|
||||
throw new \InvalidArgumentException('字段选择无效');
|
||||
}
|
||||
|
||||
$plain = [];
|
||||
$raws = [];
|
||||
|
||||
foreach ($cols as $col) {
|
||||
// 简单判断带 “ as ” 的就归到 Raw
|
||||
if (preg_match('/\s+as\s+/i', $col)) {
|
||||
$raws[] = $col;
|
||||
} else {
|
||||
$plain[] = $col;
|
||||
}
|
||||
}
|
||||
|
||||
// 先 select 普通列
|
||||
if ($plain) {
|
||||
$this->query->select($plain);
|
||||
}
|
||||
// 再 selectRaw alias
|
||||
if ($raws) {
|
||||
// 注意:selectRaw 接受一整个字符串
|
||||
$this->query->selectRaw(implode(', ', $raws));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用关联加载,支持 dot-notation 多级关联
|
||||
*/
|
||||
// protected function applyRelations2(): self
|
||||
// {
|
||||
// if (empty($this->relations)) {
|
||||
// return $this;
|
||||
// }
|
||||
|
||||
// $with = [];
|
||||
// foreach ($this->relations as $path => $fields) {
|
||||
// $with[$path] = function ($q) use ($fields) {
|
||||
// if ($fields === '*' || empty($fields)) {
|
||||
// return;
|
||||
// }
|
||||
// // 保证 id 在内
|
||||
// $model = $q->getModel();
|
||||
// $key = $model->getKeyName();
|
||||
// $q->select(array_merge([$key], $fields));
|
||||
// };
|
||||
// }
|
||||
|
||||
// $this->query->with($with);
|
||||
|
||||
// return $this;
|
||||
// }
|
||||
|
||||
/**
|
||||
* 应用多级关联加载,支持给关联字段加 alias
|
||||
*/
|
||||
protected function applyRelations2(): self
|
||||
{
|
||||
if (empty($this->relations)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$with = [];
|
||||
foreach ($this->relations as $path => $fields) {
|
||||
$with[$path] = function ($q) use ($fields) {
|
||||
if ($fields === '*' || empty($fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 模型主键一定要带上
|
||||
$model = $q->getModel();
|
||||
$key = $model->getKeyName();
|
||||
|
||||
$plain = [];
|
||||
$raws = [];
|
||||
|
||||
foreach ($fields as $col) {
|
||||
if (preg_match('/\s+as\s+/i', $col)) {
|
||||
$raws[] = $col;
|
||||
} else {
|
||||
$plain[] = $col;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保主键至少出现一次
|
||||
if (! in_array($key, $plain, true)) {
|
||||
array_unshift($plain, $key);
|
||||
}
|
||||
|
||||
// select 普通列
|
||||
if ($plain) {
|
||||
$q->select($plain);
|
||||
}
|
||||
// selectRaw 带 alias 的列
|
||||
if ($raws) {
|
||||
$q->selectRaw(implode(', ', $raws));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$this->query->with($with);
|
||||
return $this;
|
||||
}
|
||||
/**
|
||||
* 应用多级关联加载,支持字段数组或直接传入闭包
|
||||
*/
|
||||
protected function applyRelations(): self
|
||||
{
|
||||
if (empty($this->relations)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$with = [];
|
||||
foreach ($this->relations as $path => $handler) {
|
||||
if ($handler instanceof \Closure) {
|
||||
// 直接使用用户传入的闭包
|
||||
$with[$path] = $handler;
|
||||
} else {
|
||||
// 按字段数组构造查询
|
||||
$fields = $handler;
|
||||
$with[$path] = function ($q) use ($fields) {
|
||||
if ($fields === '*' || empty($fields)) {
|
||||
return;
|
||||
}
|
||||
// 确保主键被选中
|
||||
$model = $q->getModel();
|
||||
$key = $model->getKeyName();
|
||||
|
||||
$plain = [];
|
||||
$raws = [];
|
||||
foreach ($fields as $col) {
|
||||
if (preg_match('/\s+as\s+/i', $col)) {
|
||||
$raws[] = $col;
|
||||
} else {
|
||||
$plain[] = $col;
|
||||
}
|
||||
}
|
||||
if (! in_array($key, $plain, true)) {
|
||||
array_unshift($plain, $key);
|
||||
}
|
||||
|
||||
if ($plain) {
|
||||
$q->select($plain);
|
||||
}
|
||||
if ($raws) {
|
||||
$q->selectRaw(implode(', ', $raws));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$this->query->with($with);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 应用 whereHas 关联过滤
|
||||
*/
|
||||
protected function applyWhereHas(): self
|
||||
{
|
||||
foreach ($this->hasRelations as $relationData) {
|
||||
$this->query->whereHas($relationData['relation'], $relationData['callback']);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单个过滤条件
|
||||
*/
|
||||
protected function applyCondition(Builder $query, string $field, string $operator, mixed $value): void
|
||||
{
|
||||
switch (strtolower($operator)) {
|
||||
case 'between':
|
||||
$query->whereBetween($field, (array)$value);
|
||||
break;
|
||||
case 'like':
|
||||
$query->where($field, 'LIKE', "%{$value}%");
|
||||
break;
|
||||
case 'in':
|
||||
$query->whereIn($field, (array)$value);
|
||||
break;
|
||||
case 'not_in':
|
||||
$query->whereNotIn($field, (array)$value);
|
||||
break;
|
||||
case 'null':
|
||||
$query->whereNull($field);
|
||||
break;
|
||||
case 'not_null':
|
||||
$query->whereNotNull($field);
|
||||
break;
|
||||
default:
|
||||
$query->where($field, $operator, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 queryConfig 构造 fieldsMap
|
||||
* - 兼容 "col as alias"
|
||||
* - 自动把 counts 生成的 xxx_count 加入根字段
|
||||
*
|
||||
* @param array $config 完整的 applyQueryConfig 输入
|
||||
* @return array [''=>[主表字段…], 'relation'=>[字段…], …]
|
||||
*/
|
||||
public static function buildFieldsMap(array $config): array
|
||||
{
|
||||
$relations = $config['relations'] ?? [];
|
||||
$counts = $config['counts'] ?? [];
|
||||
$rawFields = $config['fields'] ?? [];
|
||||
|
||||
// 1) 根层字段:拆 alias + 自动加 count + 加顶级关联名
|
||||
$root = [];
|
||||
foreach ($rawFields as $f) {
|
||||
if (preg_match('/\s+as\s+/i', $f)) {
|
||||
[, $alias] = preg_split('/\s+as\s+/i', $f);
|
||||
$root[] = $alias;
|
||||
} else {
|
||||
$root[] = $f;
|
||||
}
|
||||
}
|
||||
// 把 withCount 生成的字段也加进来
|
||||
foreach ($counts as $rel) {
|
||||
$root[] = "{$rel}_count";
|
||||
}
|
||||
// 顶级关联名
|
||||
foreach (array_keys($relations) as $path) {
|
||||
$root[] = explode('.', $path, 2)[0];
|
||||
}
|
||||
$fieldsMap = ['' => array_values(array_unique($root))];
|
||||
|
||||
// 2) 每个关联:拆 alias + 加直接子关联
|
||||
foreach ($relations as $path => $rawCols) {
|
||||
$arr = [];
|
||||
$segments = explode('.', $path);
|
||||
$depth = count($segments);
|
||||
// 拆 alias
|
||||
foreach ($rawCols as $col) {
|
||||
if (preg_match('/\s+as\s+/i', $col)) {
|
||||
[, $alias] = preg_split('/\s+as\s+/i', $col);
|
||||
$arr[] = $alias;
|
||||
} else {
|
||||
$arr[] = $col;
|
||||
}
|
||||
}
|
||||
// 子关联名
|
||||
if ($depth > 1) {
|
||||
for ($i = 1; $i < $depth; $i++) {
|
||||
$parent = implode('.', array_slice($segments, 0, $i));
|
||||
$child = $segments[$i];
|
||||
$fieldsMap[$parent][] = $child;
|
||||
}
|
||||
}
|
||||
$fieldsMap[$path] = array_values(array_unique($arr));
|
||||
}
|
||||
|
||||
return $fieldsMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\utils;
|
||||
|
||||
|
||||
class SnGenerator
|
||||
{
|
||||
/**
|
||||
* 生成唯一的用户编号
|
||||
*
|
||||
* @param int $length 编号长度(6 或 8)
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function generateUniqueUserId($length = 6)
|
||||
{
|
||||
if (!in_array($length, [6, 8])) {
|
||||
throw new \Exception("长度必须是 6 或 8 位");
|
||||
}
|
||||
|
||||
$maxAttempts = 100; // 最大尝试次数,避免无限循环
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
$userId = $this->generateRandomNumber($length);
|
||||
$attempts++;
|
||||
if ($attempts > $maxAttempts) {
|
||||
throw new \Exception("无法生成唯一的用户编号,请重试");
|
||||
}
|
||||
} while ($this->isUserIdExists($userId));
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机数字
|
||||
*
|
||||
* @param int $length 数字长度
|
||||
* @return string
|
||||
*/
|
||||
private function generateRandomNumber($length)
|
||||
{
|
||||
$min = pow(10, $length - 1); // 最小值,如 6 位是 100000
|
||||
$max = pow(10, $length) - 1; // 最大值,如 6 位是 999999
|
||||
return (string) mt_rand($min, $max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户编号是否已存在
|
||||
*
|
||||
* @param string $userId
|
||||
* @return bool
|
||||
*/
|
||||
private function isUserIdExists($userId)
|
||||
{
|
||||
$userIds = \support\Db::table('hs_users')->pluck('user_sn')->toArray();
|
||||
return in_array($userId, $userIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\common\utils;
|
||||
|
||||
use support\Db;
|
||||
|
||||
class TableInspector
|
||||
{
|
||||
/**
|
||||
* 获取某个表的所有字段信息
|
||||
*/
|
||||
public static function getColumns(string $table, ?string $database = null): array
|
||||
{
|
||||
$database = $database ?: Db::connection()->getDatabaseName();
|
||||
|
||||
$columns = Db::select("
|
||||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE,
|
||||
COLUMN_DEFAULT, COLUMN_COMMENT, EXTRA
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
", [$database, $table]);
|
||||
|
||||
return array_map(function ($col) {
|
||||
return [
|
||||
'name' => $col->COLUMN_NAME,
|
||||
'type' => $col->DATA_TYPE,
|
||||
'column_type' => $col->COLUMN_TYPE,
|
||||
'nullable' => $col->IS_NULLABLE === 'YES',
|
||||
'default' => $col->COLUMN_DEFAULT,
|
||||
'comment' => $col->COLUMN_COMMENT,
|
||||
'extra' => $col->EXTRA,
|
||||
];
|
||||
}, $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段名列表
|
||||
*/
|
||||
public static function getColumnNames(string $table, ?string $database = null): array
|
||||
{
|
||||
return array_column(self::getColumns($table, $database), 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段注释 map:字段名 => 注释
|
||||
*/
|
||||
public static function getColumnComments(string $table, ?string $database = null): array
|
||||
{
|
||||
$columns = self::getColumns($table, $database);
|
||||
$map = [];
|
||||
foreach ($columns as $col) {
|
||||
$map[$col['name']] = $col['comment'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\generate\actions;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use app\mzl\common\utils\TableInspector;
|
||||
|
||||
class Generate
|
||||
{
|
||||
// 表名
|
||||
protected $name = '';
|
||||
|
||||
// 控制器名
|
||||
protected $convertName = '';
|
||||
|
||||
// 模块名
|
||||
protected $moduleName = '';
|
||||
|
||||
// 表前缀
|
||||
protected $dbPrefix = 'hs_';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function doGenerate()
|
||||
{
|
||||
$fs = new Filesystem();
|
||||
|
||||
// 模板路径
|
||||
$templateDir = __DIR__ . '/../templates/';
|
||||
$controllerPath = __DIR__ . "/../../../{$this->moduleName}/controller/{$this->convertName}Controller.php";
|
||||
$servicePath = __DIR__ . "/../../../{$this->moduleName}/service/{$this->convertName}Service.php";
|
||||
$modelPath = __DIR__ . "/../../../{$this->moduleName}/model/{$this->convertName}Model.php";
|
||||
$validatePath = __DIR__ . "/../../../{$this->moduleName}/validate/{$this->convertName}Validator.php";
|
||||
|
||||
// // 创建目录(如果不存在)
|
||||
$fs->mkdir(__DIR__ . "/../../../{$this->moduleName}/controller");
|
||||
$fs->mkdir(__DIR__ . "/../../../{$this->moduleName}/service");
|
||||
$fs->mkdir(__DIR__ . "/../../../{$this->moduleName}/model");
|
||||
$fs->mkdir(__DIR__ . "/../../../{$this->moduleName}/validate");
|
||||
|
||||
// // 删除已存在的文件并重新生成
|
||||
$this->deleteFileIfExists($controllerPath);
|
||||
$this->deleteFileIfExists($servicePath);
|
||||
$this->deleteFileIfExists($modelPath);
|
||||
$this->deleteFileIfExists($validatePath);
|
||||
|
||||
// 生成控制器文件
|
||||
$ControllerStub = $this->generateFile($templateDir, 'ControllerStub.stub', $controllerPath);
|
||||
|
||||
// 生成服务类文件
|
||||
$ServiceStub = $this->generateFile($templateDir, 'ServiceStub.stub', $servicePath);
|
||||
|
||||
// 生成模型类文件
|
||||
$ModelStub = $this->generateFile($templateDir, 'ModelStub.stub', $modelPath);
|
||||
|
||||
// 生成验证器类文件
|
||||
$ValidatorStub = $this->generateFile($templateDir, 'ValidatorStub.stub', $validatePath);
|
||||
|
||||
return $ControllerStub . $ServiceStub . $ModelStub . $ValidatorStub . "<info>文件生成成功!</info> <br/>";
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置字段
|
||||
*
|
||||
* @param array $fields 字段数组
|
||||
*/
|
||||
public function fillableFields()
|
||||
{
|
||||
$tableName = $this->dbPrefix.$this->name;
|
||||
$fields = TableInspector::getColumnNames($tableName);
|
||||
$str = '';
|
||||
foreach ($fields as $key => $val) {
|
||||
if ($key < (count($fields)-1)) {
|
||||
$str .= "'{$val}', ";
|
||||
} else {
|
||||
$str .= "'{$val}'";
|
||||
}
|
||||
}
|
||||
$fields = trim($str, '"');
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模板生成文件
|
||||
*
|
||||
* @param string $templateDir 模板目录
|
||||
* @param string $templateFile 模板文件名
|
||||
* @param string $outputPath 输出文件路径
|
||||
*/
|
||||
protected function generateFile($templateDir, $templateFile, $outputPath)
|
||||
{
|
||||
$templatePath = $templateDir . $templateFile;
|
||||
|
||||
if (!file_exists($templatePath)) {
|
||||
echo "<error>模板文件 {$templateFile} 不存在!</error> \n";
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($templatePath);
|
||||
$content = str_replace('{{name}}', $this->convertName, $content);
|
||||
|
||||
// 填入table名
|
||||
$content = str_replace('{{table}}', $this->dbPrefix.$this->name, $content);
|
||||
|
||||
// 填入module名
|
||||
$content = str_replace('{{module}}', $this->moduleName, $content);
|
||||
|
||||
// 填入字段
|
||||
$content = str_replace('{{fillableFields}}', $this->fillableFields(), $content);
|
||||
|
||||
file_put_contents($outputPath, $content);
|
||||
|
||||
return "生成文件: <info>{$outputPath}</info> <br/>";
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果文件存在,删除它
|
||||
*
|
||||
* @param string $path 文件路径
|
||||
*/
|
||||
protected function deleteFileIfExists($path)
|
||||
{
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
echo "删除已存在文件: <comment>{$path}</comment> \n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置名称
|
||||
*/
|
||||
public function setName($name = '')
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->convertName = ucfirst(camelize($name));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模块名称
|
||||
*/
|
||||
public function setModule($name = '')
|
||||
{
|
||||
$this->moduleName = $name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下划线转驼峰
|
||||
* 思路:
|
||||
* step1.原字符串转小写,原字符串中的分隔符用空格替换,在字符串开头加上分隔符
|
||||
* step2.将字符串中每个单词的首字母转换为大写,再去空格,去字符串首部附加的分隔符.
|
||||
*/
|
||||
function camelize($uncamelized_words, $separator='_') {
|
||||
$uncamelized_words = $separator . str_replace($separator, " ", strtolower($uncamelized_words));
|
||||
return ltrim(str_replace(" ", "", ucwords($uncamelized_words)), $separator );
|
||||
}
|
||||
|
||||
/**
|
||||
* 驼峰命名转下划线命名
|
||||
* 思路:
|
||||
* 小写和大写紧挨一起的地方,加上分隔符,然后全部转小写
|
||||
*/
|
||||
function uncamelize($camelCaps, $separator='_') {
|
||||
return strtolower(preg_replace('/([a-z])([A-Z])/', "$1" . $separator . "$2", $camelCaps));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace app\{{module}}\controller;
|
||||
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use app\{{module}}\model\{{name}}Model;
|
||||
use app\{{module}}\service\{{name}}Service;
|
||||
use app\{{module}}\validate\{{name}}Validator;
|
||||
use app\mzl\common\controller\BaseController;
|
||||
|
||||
class {{name}}Controller extends BaseController
|
||||
{
|
||||
|
||||
/**
|
||||
* 不需要验证token的接口
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedJwt = [];
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected mixed $validator = {{name}}Validator::class;
|
||||
|
||||
/**
|
||||
* 返回业务 Service 实例
|
||||
*/
|
||||
protected function getService()
|
||||
{
|
||||
return new {{name}}Service(new {{name}}Model());
|
||||
}
|
||||
|
||||
/**
|
||||
* 额外字段处理 (根据场景处理)
|
||||
* @param array $item
|
||||
* @param string $scene
|
||||
* @return array
|
||||
*/
|
||||
protected function postProcessItem(array $item, string $scene): array
|
||||
{
|
||||
|
||||
if ($scene === 'default') {
|
||||
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数预处理 (根据场景处理)
|
||||
* @param Request $request
|
||||
* @param string $scene
|
||||
* @return array
|
||||
*/
|
||||
protected function prepareParams(Request $request, string $scene): array
|
||||
{
|
||||
$params = parent::prepareParams($request, $scene);
|
||||
|
||||
if ($scene === 'default') {
|
||||
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function index(Request $request, string $scene = 'default'): Response
|
||||
{
|
||||
try {
|
||||
return parent::index($request, $scene);
|
||||
} catch (\Throwable $th) {
|
||||
return $this->errorResponse($th->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace app\{{module}}\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* {{name}}Model
|
||||
*/
|
||||
class {{name}}Model extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = '{{table}}';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* 模型的数据字段的保存格式。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = [{{fillableFields}}];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace app\{{module}}\service;
|
||||
|
||||
use app\{{module}}\model\{{name}}Model;
|
||||
use app\mzl\common\services\BaseService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class {{name}}Service extends BaseService
|
||||
{
|
||||
/**
|
||||
* 定义模型
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getModel(): string
|
||||
{
|
||||
return {{name}}Model::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多场景配置
|
||||
*/
|
||||
public function queryScenes(): array
|
||||
{
|
||||
return [
|
||||
// 'default' => function ($params) {
|
||||
// $keyword = $params['keyword'] ?? '';
|
||||
// return [
|
||||
// 'filters' => [
|
||||
// ['field' => '***', 'operator' => '=', 'value' => '***']
|
||||
// ],
|
||||
// 'fields' => ['id'],
|
||||
// 'relations' => [],
|
||||
// 'custom' => function(Builder $q) use ($keyword) {
|
||||
// // 示例
|
||||
// if ($keyword) {
|
||||
// $q->where(function($q2) use($keyword) {
|
||||
// $q2->whereHas('***', function($q3) use($keyword) {
|
||||
// $q3->where('***', 'like', "%{$keyword}%");
|
||||
// })->orWhereHas('***', function($q4) use($keyword) {
|
||||
// $q4->where('***', 'like', "%{$keyword}%");
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// 'sorts' => [
|
||||
// ['field' => 'created_at', 'direction' => 'desc']
|
||||
// ],
|
||||
// 'pagination' => [
|
||||
// 'page' => $params['page'] ?? 1,
|
||||
// 'per_page' => $params['per_page'] ?? 10,
|
||||
// ]
|
||||
// ];
|
||||
// }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\{{module}}\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class {{name}}Validator extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\jwt\middleware;
|
||||
|
||||
use ReflectionClass;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
use app\mzl\modules\jwt\service\JwtService;
|
||||
|
||||
class JwtMiddleware implements MiddlewareInterface
|
||||
{
|
||||
|
||||
protected $jwtService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->jwtService = new JwtService();
|
||||
}
|
||||
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
// 通过反射获取控制器
|
||||
$controller = new ReflectionClass($request->controller);
|
||||
$noNeedJwt = $controller->getDefaultProperties()['noNeedJwt'] ?? [];
|
||||
|
||||
// 除外
|
||||
if (in_array($request->action, $noNeedJwt)) {
|
||||
|
||||
return $handler($request);
|
||||
} else {
|
||||
try {
|
||||
$authHeader = $request->header('Authorization');
|
||||
|
||||
if (!$authHeader || !preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
||||
$body = [
|
||||
'status' => 'error',
|
||||
'code' => 401,
|
||||
'message' => '未授权',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
$token = $matches[1];
|
||||
|
||||
$request->user = $this->jwtService->verifyToken($token);
|
||||
|
||||
// 验证权限 -- start
|
||||
// $user = \app\mzl\modules\permission\model\User::find($request->user->user_id);
|
||||
// if (!$user || !$user->hasPermission($request->path())) {
|
||||
// $body = [
|
||||
// 'status' => 'error',
|
||||
// 'code' => 403,
|
||||
// 'message' => '无权限访问',
|
||||
// 'data' => [],
|
||||
// ];
|
||||
// return response(json_encode($body, JSON_UNESCAPED_UNICODE), 403, ['Content-Type' => 'application/json']);
|
||||
// }
|
||||
// 验证权限 -- end
|
||||
} catch (\Throwable $e) {
|
||||
$body = [
|
||||
'status' => 'error',
|
||||
'code' => 401,
|
||||
'message' => $e->getMessage(),
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\jwt\service;
|
||||
|
||||
use Firebase\JWT\JWT as FirebaseJWT;
|
||||
use Firebase\JWT\Key;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class JwtService
|
||||
{
|
||||
protected $secretKey;
|
||||
protected $algorithm;
|
||||
protected $issuer;
|
||||
protected $expiration;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->secretKey = getenv('JWT_SECRET_KEY') ?: 'default_secret_key';
|
||||
$this->algorithm = getenv('JWT_ALGORITHM') ?: 'HS256';
|
||||
$this->issuer = getenv('JWT_ISSUER') ?: 'domain.com';
|
||||
$this->expiration = (int)(getenv('JWT_EXPIRATION') ?: 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JWT 令牌
|
||||
*
|
||||
* @param array $payload
|
||||
* @return string
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function generateToken(array $payload): string
|
||||
{
|
||||
$defaultPayload = [
|
||||
'iss' => $this->issuer,
|
||||
'iat' => time(),
|
||||
'nbf' => time(),
|
||||
'exp' => time() + $this->expiration,
|
||||
];
|
||||
|
||||
$payload = array_merge($defaultPayload, $payload);
|
||||
|
||||
try {
|
||||
return FirebaseJWT::encode($payload, $this->secretKey, $this->algorithm);
|
||||
} catch (\Exception $e) {
|
||||
throw new BusinessException("令牌生成失败:" . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JWT 令牌
|
||||
*
|
||||
* @param string $token
|
||||
* @return object
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function verifyToken(string $token)
|
||||
{
|
||||
try {
|
||||
return FirebaseJWT::decode($token, new Key($this->secretKey, $this->algorithm));
|
||||
} catch (\Firebase\JWT\ExpiredException $e) {
|
||||
throw new BusinessException('令牌已过期', 401);
|
||||
} catch (\Firebase\JWT\SignatureInvalidException $e) {
|
||||
throw new BusinessException('无效的令牌签名', 401);
|
||||
} catch (\Firebase\JWT\BeforeValidException $e) {
|
||||
throw new BusinessException('令牌尚未生效', 401);
|
||||
} catch (\Exception $e) {
|
||||
throw new BusinessException('令牌验证失败:' . $e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE `roles` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) NOT NULL COMMENT '角色标识',
|
||||
`label` varchar(100) DEFAULT NULL COMMENT '角色名称',
|
||||
`created_at` int(11) unsigned DEFAULT '0',
|
||||
`updated_at` int(11) unsigned DEFAULT '0',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色';
|
||||
|
||||
CREATE TABLE `permissions` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) NOT NULL COMMENT '权限标识',
|
||||
`label` varchar(100) DEFAULT NULL COMMENT '权限名称',
|
||||
`created_at` int(11) unsigned DEFAULT '0',
|
||||
`updated_at` int(11) unsigned DEFAULT '0',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限';
|
||||
|
||||
CREATE TABLE `role_user` (
|
||||
`user_id` int(11) NOT NULL,
|
||||
`role_id` int(11) NOT NULL,
|
||||
PRIMARY KEY (`user_id`,`role_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色_用户';
|
||||
|
||||
CREATE TABLE `permission_role` (
|
||||
`role_id` int(11) NOT NULL,
|
||||
`permission_id` int(11) NOT NULL,
|
||||
PRIMARY KEY (`role_id`,`permission_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限_角色';
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\permission\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* Permission
|
||||
*/
|
||||
class Permission extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'permissions';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* 模型的数据字段的保存格式。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'name', 'label', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
public function roles()
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'permission_role');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\permission\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* Role
|
||||
*/
|
||||
class Role extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'roles';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* 模型的数据字段的保存格式。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'name', 'label', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
public function permissions()
|
||||
{
|
||||
return $this->belongsToMany(Permission::class, 'permission_role');
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'role_user');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\permission\model;
|
||||
|
||||
use support\Model;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* User
|
||||
*/
|
||||
class User extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'wzh_user';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* 模型的数据字段的保存格式。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'password', 'realname', 'idcard', 'gzh_openid', 'identity', 'role_id', 'sex', 'phone', 'created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
public function roles()
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_user');
|
||||
}
|
||||
|
||||
public function hasPermission2(string $permission): bool
|
||||
{
|
||||
// 加载角色和每个角色的权限
|
||||
$roles = $this->roles()->with('permissions')->get();
|
||||
// 判断是否有某个权限
|
||||
return $roles->pluck('permissions')->flatten()->pluck('name')->contains($permission);
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission, int $ttl = 24 * 60 * 60): bool
|
||||
{
|
||||
$cacheKey = 'user_permissions_' . $this->id;
|
||||
$permissions = Redis::get($cacheKey);
|
||||
if (!$permissions) {
|
||||
$roles = $this->roles()->with('permissions')->get();
|
||||
$permissions = $roles->pluck('permissions')->flatten()->pluck('name')->toArray();
|
||||
Redis::setEx($cacheKey, $ttl, json_encode($permissions));
|
||||
} else {
|
||||
$permissions = json_decode($permissions, true);
|
||||
}
|
||||
return in_array($permission, $permissions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Excel 导入配置
|
||||
*/
|
||||
return [
|
||||
'student' => [
|
||||
'column_map' => [
|
||||
'A' => 'index',
|
||||
'B' => 'company',
|
||||
'C' => 'idcard',
|
||||
'D' => 'name',
|
||||
'E' => 'gender',
|
||||
'F' => 'nation',
|
||||
'G' => 'education',
|
||||
'H' => 'position',
|
||||
'I' => 'train_type',
|
||||
'J' => 'exam_type',
|
||||
'K' => 'phone',
|
||||
'L' => 'address',
|
||||
'M' => 'erp_code',
|
||||
]
|
||||
],
|
||||
'user' => [
|
||||
'column_map' => [
|
||||
'A' => 'realname',
|
||||
'B' => 'sex',
|
||||
'C' => 'phone',
|
||||
'D' => 'idcard',
|
||||
'E' => 'identity'
|
||||
]
|
||||
]
|
||||
];
|
||||
@@ -0,0 +1,606 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\phpoffice\service;
|
||||
|
||||
use support\Db;
|
||||
// use support\Log;
|
||||
use support\exception\BusinessException;
|
||||
use app\api\model\PracticeItemsModel;
|
||||
use app\api\model\PracticeItemsScoreModel;
|
||||
use app\api\model\PracticeModel;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
|
||||
// use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
// use PhpOffice\PhpSpreadsheet\Shared\Drawing as SharedDrawing;
|
||||
// use PhpOffice\PhpSpreadsheet\Writer\Pdf\Dompdf as PdfWriter;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Pdf\Mpdf as PdfWriter;
|
||||
use Mpdf\Config\ConfigVariables;
|
||||
use Mpdf\Config\FontVariables;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Pdf\Tcpdf as TcPdfWriter;
|
||||
|
||||
class ExcelService
|
||||
{
|
||||
|
||||
// 配置
|
||||
protected $config = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = getConfig('/mzl/modules/phpoffice/config/excel_import.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入Excel数据
|
||||
*/
|
||||
public function importToData(string $template_key, string $filePath): array
|
||||
{
|
||||
$config = $this->config[$template_key] ?? null;
|
||||
if (!$config) {
|
||||
throw new BusinessException("未找到模板配置:$template_key");
|
||||
}
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, true);
|
||||
$callback = $this->callback($template_key) ?? null;
|
||||
return $this->toData($rows, $config['column_map'], $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件回调
|
||||
*/
|
||||
public function callback(string $template_key)
|
||||
{
|
||||
switch ($template_key) {
|
||||
case 'student':
|
||||
$callback = function ($row, $i) {
|
||||
return $i < 3 || empty($row['C']);
|
||||
};
|
||||
break;
|
||||
case 'user':
|
||||
$callback = function ($row, $i) {
|
||||
return $i < 2 || empty($row['D']);
|
||||
};
|
||||
break;
|
||||
}
|
||||
return $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据
|
||||
*/
|
||||
public function toData(array $rows = [], array $columnMap = [], $callback = null)
|
||||
{
|
||||
$data = [];
|
||||
foreach ($rows as $i => $row) {
|
||||
if ($callback && $callback($row, $i)) {
|
||||
continue;
|
||||
}
|
||||
$item = [];
|
||||
foreach ($columnMap as $excelKey => $dbField) {
|
||||
$item[$dbField] = trim((string)($row[$excelKey] ?? ''));
|
||||
}
|
||||
$data[] = $item;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入practice
|
||||
*/
|
||||
public function importPractice(string $filePath)
|
||||
{
|
||||
Db::beginTransaction();
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
$rows = $sheet->toArray();
|
||||
|
||||
// 解析标题行
|
||||
$titleRow = trim($rows[0][0]);
|
||||
preg_match('/(.*?)\s+([A-Za-z0-9-]+\/[A-Za-z0-9]+)$/', $titleRow, $matches);
|
||||
|
||||
$practice = PracticeModel::create([
|
||||
'sn' => generateRandomSN(),
|
||||
'title' => $matches[1] ?? '未知标题',
|
||||
'code' => $matches[2] ?? '未编号'
|
||||
]);
|
||||
|
||||
$currentItem = null;
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
// 跳过表头前两行(标题行+列头行)
|
||||
if ($index < 2 || empty(array_filter($row))) continue;
|
||||
|
||||
// 处理主检查项
|
||||
if (!empty($row[0]) && is_numeric($row[0])) {
|
||||
$currentItem = PracticeItemsModel::create([
|
||||
'practice_id' => $practice->id,
|
||||
'index' => $row[0],
|
||||
'exam_content' => $row[1],
|
||||
'scoring_element' => $row[2],
|
||||
'score_allocation' => $row[3]
|
||||
]);
|
||||
}
|
||||
|
||||
// 处理评分标准
|
||||
if ($currentItem && !empty($row[4])) {
|
||||
PracticeItemsScoreModel::create([
|
||||
'practice_item_id' => $currentItem->id,
|
||||
'scoring_criteria' => $row[4],
|
||||
'max_deduction' => $row[5],
|
||||
'single_deduction' => $row[6]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ====== ====== ====== 公共方法 ====== ====== ======
|
||||
private function setColumnWidths($sheet, array $widths)
|
||||
{
|
||||
foreach ($widths as $column => $width) {
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
}
|
||||
}
|
||||
private function setRowHeights($sheet, array $heights)
|
||||
{
|
||||
foreach ($heights as $row => $height) {
|
||||
$sheet->getRowDimension($row)->setRowHeight($height);
|
||||
}
|
||||
}
|
||||
private function centerAlignAllCells($sheet)
|
||||
{
|
||||
$maxRow = $sheet->getHighestRow();
|
||||
$maxCol = $sheet->getHighestColumn();
|
||||
$sheet->getStyle("A1:{$maxCol}{$maxRow}")->applyFromArray([
|
||||
'alignment' => [
|
||||
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
|
||||
]
|
||||
]);
|
||||
}
|
||||
private function setGlobalFont($sheet, $start = null, $end = null, $name = '宋体', $size = 10)
|
||||
{
|
||||
if ($start && $end) {
|
||||
$fw = "{$start}:{$end}";
|
||||
} else {
|
||||
$maxRow = $sheet->getHighestRow();
|
||||
$maxCol = $sheet->getHighestColumn();
|
||||
$fw = "A1:{$maxCol}{$maxRow}";
|
||||
}
|
||||
$sheet->getStyle($fw)->applyFromArray([
|
||||
'font' => [
|
||||
'name' => $name,
|
||||
'size' => $size,
|
||||
],
|
||||
]);
|
||||
}
|
||||
// ====== ====== ====== 公共方法 ====== ====== ======
|
||||
|
||||
|
||||
/**
|
||||
* 导出 班级总结
|
||||
*/
|
||||
public function exportClassSummary2(array $data, string $outputPath = '班级总结', ?string $signatureImage = null)
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// ====== 1. 标题 & 基础信息 ======
|
||||
$sheet->setCellValue('A1', $data['title'] ?? 'XX(班级名称)班级信息');
|
||||
// 标题行立即合并
|
||||
$sheet->mergeCells('A1:C1');
|
||||
|
||||
$basicInfo = [
|
||||
['开班地点', 'location'],
|
||||
['开班类型', 'type'],
|
||||
['培训项目', 'program'],
|
||||
['授课老师', 'teacher'],
|
||||
['培训时间', 'time'],
|
||||
['参培学员', 'students'],
|
||||
['合格人员', 'passed'],
|
||||
['通过率', 'pass_rate'],
|
||||
];
|
||||
$row = 2;
|
||||
foreach ($basicInfo as $info) {
|
||||
[$label, $key] = $info;
|
||||
$sheet->setCellValue("A{$row}", $label);
|
||||
$sheet->setCellValue("B{$row}", $data[$key] ?? '');
|
||||
$sheet->mergeCells("B{$row}:C{$row}");
|
||||
$row++;
|
||||
}
|
||||
|
||||
// ====== 2. 理论考试 ======
|
||||
if (! empty($data['theory_exams'])) {
|
||||
foreach ($data['theory_exams'] as $idx => $exam) {
|
||||
$subject = $exam['subject'] ?? "科目" . ($idx + 1);
|
||||
$fields = [
|
||||
'考试时间' => 'time',
|
||||
'考试地点' => 'location',
|
||||
'主监考' => 'main_invigilator',
|
||||
'监考人员' => 'invigilators',
|
||||
'不及格人数' => 'fail_count',
|
||||
'合格率' => 'pass_rate',
|
||||
];
|
||||
$blockSize = count($fields);
|
||||
$startRow = $row;
|
||||
$endRow = $row + $blockSize - 1;
|
||||
|
||||
$sheet->mergeCells("A{$startRow}:A{$endRow}");
|
||||
$sheet->setCellValue("A{$startRow}", "理论考试信息({$subject})");
|
||||
|
||||
$r = $startRow;
|
||||
foreach ($fields as $label => $key) {
|
||||
$sheet->setCellValue("B{$r}", $label);
|
||||
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
|
||||
$r++;
|
||||
}
|
||||
|
||||
$row = $endRow + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 3. 实操考试 ======
|
||||
if (! empty($data['practice_exams'])) {
|
||||
foreach ($data['practice_exams'] as $idx => $exam) {
|
||||
$subject = $exam['subject'] ?? "实操" . ($idx + 1);
|
||||
$fields = [
|
||||
'考试科目' => 'subject',
|
||||
'考试时间' => 'time',
|
||||
'考试地点' => 'location',
|
||||
'不及格人数' => 'fail_count',
|
||||
'合格率' => 'pass_rate',
|
||||
];
|
||||
$blockSize = count($fields);
|
||||
$startRow = $row;
|
||||
$endRow = $row + $blockSize - 1;
|
||||
|
||||
$sheet->mergeCells("A{$startRow}:A{$endRow}");
|
||||
$sheet->setCellValue("A{$startRow}", "实操考试信息({$subject})");
|
||||
|
||||
$r = $startRow;
|
||||
foreach ($fields as $label => $key) {
|
||||
$sheet->setCellValue("B{$r}", $label);
|
||||
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
|
||||
$r++;
|
||||
}
|
||||
|
||||
$row = $endRow + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 5. 样式:合并、边框、对齐、行高、字体 ======
|
||||
$lastRow = $row;
|
||||
|
||||
// 全区域合并边框
|
||||
$sheet->getStyle("A1:C{$lastRow}")->applyFromArray([
|
||||
'borders' => ['allBorders'=>['borderStyle'=>Border::BORDER_THIN]],
|
||||
]);
|
||||
|
||||
// 对齐
|
||||
$maxCol = $sheet->getHighestColumn();
|
||||
$sheet->getStyle("A1:{$maxCol}{$lastRow}")->applyFromArray([
|
||||
'alignment'=>[
|
||||
'horizontal'=>\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' =>\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
|
||||
]
|
||||
]);
|
||||
|
||||
// 字体
|
||||
// 标题行
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font'=>['name'=>'黑体','size'=>22],
|
||||
]);
|
||||
// 正文字体
|
||||
$sheet->getStyle("A2:C{$lastRow}")->applyFromArray([
|
||||
'font'=>['name'=>'宋体','size'=>10],
|
||||
]);
|
||||
// 班主任行单独字号 12
|
||||
$sheet->getStyle("A{$row}:C{$row}")->applyFromArray([
|
||||
'font'=>['name'=>'宋体','size'=>12],
|
||||
]);
|
||||
|
||||
// 行高
|
||||
$sheet->getRowDimension(1)->setRowHeight(36);
|
||||
for ($r = 2; $r < $lastRow; $r++) {
|
||||
$sheet->getRowDimension($r)->setRowHeight(21.75);
|
||||
}
|
||||
$sheet->getRowDimension($lastRow)->setRowHeight(54);
|
||||
|
||||
// 列宽
|
||||
$sheet->getColumnDimension('A')->setWidth(30);
|
||||
$sheet->getColumnDimension('B')->setWidth(30);
|
||||
$sheet->getColumnDimension('C')->setWidth(24);
|
||||
|
||||
// ====== 4. 班主任签名(水平+垂直居中) ======
|
||||
$sheet->setCellValue("A{$row}", '班主任');
|
||||
// 合并 B、C 列
|
||||
$sheet->mergeCells("B{$row}:C{$row}");
|
||||
|
||||
if ($signatureImage && file_exists($signatureImage)) {
|
||||
$drawing = new Drawing();
|
||||
$drawing->setPath($signatureImage);
|
||||
// 先指定高度,等比例缩放宽度
|
||||
$drawing->setHeight(40);
|
||||
$drawing->setResizeProportional(true);
|
||||
// 锚定到 B{$row} 单元格
|
||||
$drawing->setCoordinates("B{$row}");
|
||||
// 必须先 add 到工作表,才能正确计算后续的 getWidth()/getHeight()
|
||||
$drawing->setWorksheet($sheet);
|
||||
|
||||
// —— 计算区域像素尺寸 ——
|
||||
// 列宽单位转像素近似:1 单位 ≈ 7px
|
||||
// 行高点(pt)转像素近似:1pt ≈ 96/72 px
|
||||
$pxPerWidth = 7;
|
||||
$pxPerPoint = 96 / 72;
|
||||
|
||||
$widthB = $sheet->getColumnDimension('B')->getWidth();
|
||||
$widthC = $sheet->getColumnDimension('C')->getWidth();
|
||||
$totalWidthPx = ($widthB + $widthC) * $pxPerWidth;
|
||||
|
||||
$rowHeightPt = $sheet->getRowDimension($row)->getRowHeight();
|
||||
$totalHeightPx = $rowHeightPt * $pxPerPoint;
|
||||
|
||||
// 这时 getWidth()/getHeight() 才会返回渲染后的像素值
|
||||
$imageWidthPx = $drawing->getWidth();
|
||||
$imageHeightPx = $drawing->getHeight();
|
||||
|
||||
// 计算居中偏移
|
||||
$offsetX = max(0, ($totalWidthPx - $imageWidthPx) / 2);
|
||||
$offsetY = max(0, ($totalHeightPx - $imageHeightPx) / 2);
|
||||
|
||||
$drawing->setOffsetX($offsetX);
|
||||
$drawing->setOffsetY($offsetY);
|
||||
} else {
|
||||
$sheet->setCellValue("B{$row}", $data['class_teacher'] ?? '');
|
||||
}
|
||||
|
||||
// 最后保存
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($outputPath);
|
||||
}
|
||||
|
||||
|
||||
public function exportClassSummary(array $data, string $outputPath = '班级总结', ?string $signatureImage = null)
|
||||
{
|
||||
$spreadsheet = $this->buildClassSummarySpreadsheet($data, $signatureImage);
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($outputPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建班级总结的 Spreadsheet 对象(复用给 XLSX 和 PDF)
|
||||
*
|
||||
* @param array $data
|
||||
* @param string|null $signatureImage
|
||||
* @return Spreadsheet
|
||||
*/
|
||||
protected function buildClassSummarySpreadsheet(array $data, ?string $signatureImage = null): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
//
|
||||
// 1. 标题 & 基础信息
|
||||
//
|
||||
$sheet->setCellValue('A1', $data['title'] ?? 'XX(班级名称)班级信息');
|
||||
$sheet->mergeCells('A1:C1');
|
||||
|
||||
$basicInfo = [
|
||||
['开班地点', 'location'],
|
||||
['开班类型', 'type'],
|
||||
['培训项目', 'program'],
|
||||
['授课老师', 'teacher'],
|
||||
['培训时间', 'time'],
|
||||
['参培学员', 'students'],
|
||||
['合格人员', 'passed'],
|
||||
['通过率', 'pass_rate'],
|
||||
];
|
||||
$row = 2;
|
||||
foreach ($basicInfo as $info) {
|
||||
[$label, $key] = $info;
|
||||
$sheet->setCellValue("A{$row}", $label);
|
||||
$sheet->setCellValue("B{$row}", $data[$key] ?? '');
|
||||
$sheet->mergeCells("B{$row}:C{$row}");
|
||||
$row++;
|
||||
}
|
||||
|
||||
//
|
||||
// 2. 理论考试(theory_exams)
|
||||
//
|
||||
if (! empty($data['theory_exams']) && is_array($data['theory_exams'])) {
|
||||
foreach ($data['theory_exams'] as $idx => $exam) {
|
||||
$subject = $exam['subject'] ?? '科目' . ($idx + 1);
|
||||
$fields = [
|
||||
'考试时间' => 'time',
|
||||
'考试地点' => 'location',
|
||||
'主监考' => 'main_invigilator',
|
||||
'监考人员' => 'invigilators',
|
||||
'不及格人数' => 'fail_count',
|
||||
'合格率' => 'pass_rate',
|
||||
];
|
||||
$blockSize = count($fields);
|
||||
$startRow = $row;
|
||||
$endRow = $row + $blockSize - 1;
|
||||
|
||||
$sheet->mergeCells("A{$startRow}:A{$endRow}");
|
||||
$sheet->setCellValue("A{$startRow}", "理论考试信息({$subject})");
|
||||
|
||||
$r = $startRow;
|
||||
foreach ($fields as $label => $key) {
|
||||
$sheet->setCellValue("B{$r}", $label);
|
||||
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
|
||||
$r++;
|
||||
}
|
||||
|
||||
$row = $endRow + 1;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// 3. 实操考试(practice_exams)
|
||||
//
|
||||
if (! empty($data['practice_exams']) && is_array($data['practice_exams'])) {
|
||||
foreach ($data['practice_exams'] as $idx => $exam) {
|
||||
$subject = $exam['subject'] ?? '实操' . ($idx + 1);
|
||||
$fields = [
|
||||
'考试科目' => 'subject',
|
||||
'考试时间' => 'time',
|
||||
'考试地点' => 'location',
|
||||
'不及格人数' => 'fail_count',
|
||||
'合格率' => 'pass_rate',
|
||||
];
|
||||
$blockSize = count($fields);
|
||||
$startRow = $row;
|
||||
$endRow = $row + $blockSize - 1;
|
||||
|
||||
$sheet->mergeCells("A{$startRow}:A{$endRow}");
|
||||
$sheet->setCellValue("A{$startRow}", "实操考试信息({$subject})");
|
||||
|
||||
$r = $startRow;
|
||||
foreach ($fields as $label => $key) {
|
||||
$sheet->setCellValue("B{$r}", $label);
|
||||
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
|
||||
$r++;
|
||||
}
|
||||
|
||||
$row = $endRow + 1;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// 4. 样式预设:列宽、行高、边框、对齐、字体
|
||||
//
|
||||
// 列宽
|
||||
$sheet->getColumnDimension('A')->setWidth(30);
|
||||
$sheet->getColumnDimension('B')->setWidth(30);
|
||||
$sheet->getColumnDimension('C')->setWidth(24);
|
||||
|
||||
// 当前签名行号
|
||||
$lastRow = $row;
|
||||
|
||||
// 行高
|
||||
$sheet->getRowDimension(1)->setRowHeight(36);
|
||||
for ($r = 2; $r < $lastRow; $r++) {
|
||||
$sheet->getRowDimension($r)->setRowHeight(21.75);
|
||||
}
|
||||
$sheet->getRowDimension($lastRow)->setRowHeight(54);
|
||||
|
||||
// 边框 + 对齐 + 正文字体
|
||||
$sheet->getStyle("A1:C{$lastRow}")->applyFromArray([
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
],
|
||||
'font' => ['name' => '宋体', 'size' => 10],
|
||||
]);
|
||||
|
||||
// 标题行字体
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font' => ['name' => '黑体', 'size' => 22],
|
||||
]);
|
||||
|
||||
//
|
||||
// 5. 班主任签名 & 图片居中
|
||||
//
|
||||
$sheet->setCellValue("A{$lastRow}", '班主任');
|
||||
$sheet->mergeCells("B{$lastRow}:C{$lastRow}");
|
||||
|
||||
if ($signatureImage && file_exists($signatureImage)) {
|
||||
$drawing = new Drawing();
|
||||
$drawing->setPath($signatureImage);
|
||||
$drawing->setResizeProportional(true);
|
||||
$drawing->setHeight(40);
|
||||
$drawing->setCoordinates("B{$lastRow}");
|
||||
$drawing->setWorksheet($sheet);
|
||||
|
||||
// 近似像素转换系数
|
||||
$pxPerColWidth = 7;
|
||||
$pxPerPoint = 96 / 72;
|
||||
|
||||
// 区域宽度 & 高度
|
||||
$wB = $sheet->getColumnDimension('B')->getWidth();
|
||||
$wC = $sheet->getColumnDimension('C')->getWidth();
|
||||
$areaW = ($wB + $wC) * $pxPerColWidth;
|
||||
$hPt = $sheet->getRowDimension($lastRow)->getRowHeight();
|
||||
$areaH = $hPt * $pxPerPoint;
|
||||
|
||||
// 图片渲染后尺寸
|
||||
$imgW = $drawing->getWidth();
|
||||
$imgH = $drawing->getHeight();
|
||||
|
||||
// 计算偏移并应用
|
||||
$drawing->setOffsetX(max(0, ($areaW - $imgW) / 2));
|
||||
$drawing->setOffsetY(max(0, ($areaH - $imgH) / 2));
|
||||
} else {
|
||||
// 文字班主任行单独字号
|
||||
$sheet->getStyle("B{$lastRow}:C{$lastRow}")->applyFromArray([
|
||||
'font' => ['name' => '宋体', 'size' => 12],
|
||||
]);
|
||||
$sheet->setCellValue("B{$lastRow}", $data['class_teacher'] ?? '');
|
||||
}
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 PDF 文件
|
||||
*
|
||||
* @param array $data
|
||||
* @param string $pdfPath
|
||||
* @param string|null $signatureImage
|
||||
*/
|
||||
public function exportClassSummaryPdf(array $data, string $pdfPath, ?string $signatureImage = null)
|
||||
{
|
||||
// 1. 复用生成表格的逻辑
|
||||
$spreadsheet = $this->buildClassSummarySpreadsheet($data, $signatureImage);
|
||||
|
||||
// // 2) mPDF 默认目录和字体数据
|
||||
// $configVars = new ConfigVariables();
|
||||
// $fontDirs = $configVars->getDefaults()['fontDir'];
|
||||
// $fontVars = new FontVariables();
|
||||
// $fontData = $fontVars->getDefaults()['fontdata'];
|
||||
// $myFontDir = base_path('resources/fonts');
|
||||
// $mpdfConfig = [
|
||||
// 'mode' => 'utf-8',
|
||||
// 'format' => 'A4',
|
||||
// 'fontDir' => array_merge($fontDirs, [$myFontDir]),
|
||||
// 'fontdata' => array_merge($fontData, [
|
||||
// 'sourcehansc' => [
|
||||
// 'R' => 'SourceHanSans-Regular.ttf',
|
||||
// 'B' => 'SourceHanSans-Bold.ttf',
|
||||
// ]
|
||||
// ]),
|
||||
// 'default_font'=> 'sourcehansc',
|
||||
// 'autoScriptToLang'=> true,
|
||||
// 'autoLangToFont' => true,
|
||||
// ];
|
||||
// // 2. 用 mPDF Writer 输出 PDF
|
||||
// $pdfWriter = new PdfWriter($spreadsheet, $mpdfConfig);
|
||||
// $pdfWriter->setFont('宋体');
|
||||
// $pdfWriter->setPaperSize(PageSetup::PAPERSIZE_A4);
|
||||
// $pdfWriter->setOrientation(PageSetup::ORIENTATION_PORTRAIT);
|
||||
// $pdfWriter->save($pdfPath);
|
||||
|
||||
|
||||
// 2. 用 TCPDF Writer 输出 PDF
|
||||
$tcpdfWriter = new TcPdfWriter($spreadsheet);
|
||||
$tcpdfWriter->setFont('cid0cs');
|
||||
$tcpdfWriter->setPaperSize(PageSetup::PAPERSIZE_A4);
|
||||
$tcpdfWriter->setOrientation(PageSetup::ORIENTATION_PORTRAIT);
|
||||
$tcpdfWriter->save($pdfPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 定义所有需要多语言支持的实体
|
||||
'entities' => [
|
||||
'vehicle' => [
|
||||
'translation_model' => \app\console\model\VehicleDataTranslationModel::class,
|
||||
'foreign_key' => 'vehicle_id',
|
||||
],
|
||||
'energy' => [
|
||||
'translation_model' => \app\console\model\EnergyTranslationModel::class,
|
||||
'foreign_key' => 'energy_id',
|
||||
],
|
||||
'type' => [
|
||||
'translation_model' => \app\console\model\TypesTranslationModel::class,
|
||||
'foreign_key' => 'type_id',
|
||||
],
|
||||
'model' => [
|
||||
'translation_model' => \app\console\model\ModelTranslationModel::class,
|
||||
'foreign_key' => 'model_id',
|
||||
],
|
||||
'brand' => [
|
||||
'translation_model' => \app\console\model\BrandTranslationModel::class,
|
||||
'foreign_key' => 'brand_id',
|
||||
],
|
||||
'agreement' => [
|
||||
'translation_model' => \app\console\model\AgreementTranslationModel::class,
|
||||
'foreign_key' => 'agreement_id',
|
||||
],
|
||||
'location' => [
|
||||
'translation_model' => \app\console\model\LocationTranslationModel::class,
|
||||
'foreign_key' => 'location_id',
|
||||
]
|
||||
],
|
||||
|
||||
// 默认支持的目标语种列表
|
||||
'locales' => ['zh','en','ru','ar'],
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
namespace app\mzl\modules\translation\jobs;
|
||||
|
||||
use support\Log;
|
||||
use app\mzl\modules\translation\service\TranslatorClient;
|
||||
|
||||
class TranslationJob
|
||||
{
|
||||
protected $config = [];
|
||||
protected array $payload;
|
||||
|
||||
public function __construct(array $payload)
|
||||
{
|
||||
$this->config = getConfig('/mzl/modules/translation/config/trans.php');
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cfgAll = $this->config['entities'];
|
||||
$pt = $this->payload;
|
||||
$type = $pt['entityType'];
|
||||
$id = $pt['entityId'];
|
||||
$locale = $pt['locale'];
|
||||
$sourceLocale = $pt['sourceLocale'];
|
||||
$sourceFields = $pt['sourceFields'];
|
||||
$clientFields = $pt['clientFields'];
|
||||
|
||||
if (! isset($cfgAll[$type])) {
|
||||
Log::warning("TranslationJob: 未配置实体 {$type}");
|
||||
return;
|
||||
}
|
||||
|
||||
$cfg = $cfgAll[$type];
|
||||
$modelClass = $cfg['translation_model'];
|
||||
$foreignKey = $cfg['foreign_key'];
|
||||
|
||||
// 决定采用前端提供、源语种还是第三方翻译
|
||||
if (! empty($clientFields)) {
|
||||
$fields = $clientFields;
|
||||
} elseif ($locale === $sourceLocale) {
|
||||
$fields = $sourceFields;
|
||||
} else {
|
||||
$translator = new TranslatorClient();
|
||||
$fields = [];
|
||||
foreach ($sourceFields as $key => $text) {
|
||||
$fields[$key] = $translator->translate($text, $sourceLocale, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
// 写库
|
||||
try {
|
||||
$modelClass::updateOrCreate(
|
||||
[
|
||||
$foreignKey => $id,
|
||||
'locale' => $locale,
|
||||
],
|
||||
array_merge($fields, ['locale' => $locale])
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("TranslationJob 保存失败: {$type}#{$id} locale={$locale}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\translation\queue;
|
||||
|
||||
use support\Log;
|
||||
use Webman\RedisQueue\Consumer;
|
||||
use app\mzl\modules\translation\jobs\TranslationJob;
|
||||
|
||||
class Translation implements Consumer
|
||||
{
|
||||
// 要消费的队列名
|
||||
public $queue = 'transTask';
|
||||
|
||||
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
|
||||
public $connection = 'default';
|
||||
|
||||
// 消费
|
||||
public function consume($data)
|
||||
{
|
||||
try {
|
||||
// 无需反序列化
|
||||
// var_export($data); // 输出 ['to' => 'tom@gmail.com', 'content' => 'hello']
|
||||
|
||||
$job = new TranslationJob($data);
|
||||
$job->handle();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("TranslationService 异步任务异常: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
// 消费失败回调
|
||||
/*
|
||||
$package = [
|
||||
'id' => 1357277951, // 消息ID
|
||||
'time' => 1709170510, // 消息时间
|
||||
'delay' => 0, // 延迟时间
|
||||
'attempts' => 2, // 消费次数
|
||||
'queue' => 'send-mail', // 队列名
|
||||
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
|
||||
'max_attempts' => 5, // 最大重试次数
|
||||
'error' => '错误信息' // 错误信息
|
||||
]
|
||||
*/
|
||||
public function onConsumeFailure(\Throwable $e, $package)
|
||||
{
|
||||
echo "consume failure\n";
|
||||
echo $e->getMessage() . "\n";
|
||||
// 无需反序列化
|
||||
var_export($package);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace app\mzl\modules\translation\service;
|
||||
|
||||
// use Webman\RedisQueue\Client;
|
||||
use Webman\RedisQueue\Redis;
|
||||
|
||||
class TranslationService
|
||||
{
|
||||
|
||||
// 配置
|
||||
protected $config = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = getConfig('/mzl/modules/translation/config/trans.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步排队所有语种的翻译任务
|
||||
*
|
||||
* @param string $entityType 实体名,如 'vehicle'
|
||||
* @param int $entityId 主表 ID
|
||||
* @param string $sourceLocale 源语种,如 'zh'
|
||||
* @param array $sourceFields 源语种字段 ['name'=>…,'condition_desc'=>…]
|
||||
* @param array $clientFields 前端已传翻译 ['en'=>[...], …]
|
||||
*/
|
||||
public function queueTranslations(string $entityType, int $entityId, string $sourceLocale, array $sourceFields, array $clientFields = []): void
|
||||
{
|
||||
foreach ($this->config['locales'] as $locale) {
|
||||
$payload = [
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'locale' => $locale,
|
||||
'sourceLocale' => $sourceLocale,
|
||||
'sourceFields' => $sourceFields,
|
||||
'clientFields' => $clientFields[$locale] ?? [],
|
||||
];
|
||||
Redis::send('transTask', $payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
namespace app\mzl\modules\translation\service;
|
||||
|
||||
use support\Log;
|
||||
use app\mzl\common\org\XunfeiFanyi;
|
||||
|
||||
class TranslatorClient
|
||||
{
|
||||
|
||||
// protected string $endpoint;
|
||||
// protected string $apiKey;
|
||||
|
||||
// public function __construct()
|
||||
// {
|
||||
// $this->endpoint = env('TRANSLATION_API_URL');
|
||||
// $this->apiKey = env('TRANSLATION_API_KEY');
|
||||
// }
|
||||
|
||||
/**
|
||||
* 翻译文本
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function translate(string $text, string $from = 'cn', string $to = 'ru'): string
|
||||
{
|
||||
try {
|
||||
$xunfei = new XunfeiFanyi();
|
||||
$rst_dst = $xunfei->fanyi($text, $from, $to);
|
||||
if ($rst_dst) {
|
||||
return $rst_dst["trans_result"]["dst"];
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("TranslatorClient 错误: ".$e->getMessage());
|
||||
throw new \RuntimeException('翻译服务调用失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\upload\controller;
|
||||
|
||||
use support\Request;
|
||||
use Shopwwi\WebmanFilesystem\Facade\Storage;
|
||||
use app\mzl\modules\upload\service\OssService;
|
||||
use app\mzl\common\traits\ResponseTrait;
|
||||
|
||||
class UploadController
|
||||
{
|
||||
|
||||
use ResponseTrait;
|
||||
|
||||
public function upload(Request $request)
|
||||
{
|
||||
try {
|
||||
$scene = $request->input('scene', 'image');
|
||||
$file = $request->file('file');
|
||||
if ($file && $file->isValid()) {
|
||||
$pathUrl = '/uploads/'.$scene.'/'.date('Ymd').'/'.md5($file->getUploadName().time()).'.'.$file->getUploadExtension();
|
||||
$fullPathUrl = public_path().$pathUrl;
|
||||
$file->move($fullPathUrl);
|
||||
$data = [
|
||||
'domain' => getenv('MAIN_DOMAIN'),
|
||||
'file_url' => $pathUrl,
|
||||
'full_url' => getenv('MAIN_DOMAIN').$pathUrl,
|
||||
'scene' => $scene,
|
||||
];
|
||||
return $this->successResponse($data, 'upload success');
|
||||
}
|
||||
return $this->errorResponse('file not found');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->errorResponse($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 上传单文件图片
|
||||
public function uploadImage(Request $request)
|
||||
{
|
||||
try {
|
||||
$file = $request->file('image');
|
||||
|
||||
if (!$file || !$file->isValid()) {
|
||||
return $this->errorResponse('上传失败');
|
||||
}
|
||||
|
||||
$result = Storage::adapter('oss')
|
||||
->path('vehicle_album/'.date('Ymd'))
|
||||
->size(1024*1024*5)
|
||||
->extYes(['image/jpeg','image/png'])
|
||||
->extNo(['image/gif'])
|
||||
->upload($file);
|
||||
|
||||
$ossservice = new OssService();
|
||||
|
||||
$data = [
|
||||
'image_name' => $result->file_name,
|
||||
'image_url' => $ossservice->generatePrivateUrl($result->file_name)
|
||||
];
|
||||
|
||||
return $this->successResponse($data);
|
||||
}catch (\Throwable $e){
|
||||
var_dump($e->getFile().'-'.$e->getLine().'-'.$e->getMessage());
|
||||
return $this->errorResponse($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\upload\service;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class LocalService
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
public function upload($file, $path = '', $type = 'excel')
|
||||
{
|
||||
// $file = $request->file('file');
|
||||
if ($file && $file->isValid()) {
|
||||
$pathUrl = $path == '' ? '/uploads/'.$type.'/'.date('Ymd').'/'.md5($file->getUploadName().time()).'.'.$file->getUploadExtension() : $path;
|
||||
$fullPathUrl = public_path().$pathUrl;
|
||||
$file->move($fullPathUrl);
|
||||
$data = [
|
||||
'file_url' => $pathUrl,
|
||||
'full_url' => getenv('MAIN_DOMAIN').$pathUrl
|
||||
];
|
||||
return $data;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\upload\service;
|
||||
|
||||
use OSS\OssClient;
|
||||
use OSS\Core\OssException;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class OssService
|
||||
{
|
||||
private $accessKeyId;
|
||||
private $accessKeySecret;
|
||||
private $bucket;
|
||||
private $endpoint;
|
||||
private $ossClient;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accessKeyId = getenv('OSS_ACCESSID');
|
||||
$this->accessKeySecret = getenv('OSS_ACCESSSECRET');
|
||||
$this->bucket = getenv('OSS_BUCKET');
|
||||
$this->endpoint = getenv('OSS_ENDPOINT');
|
||||
$this->ossClient = new OssClient($this->accessKeyId, $this->accessKeySecret, $this->endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片到 OSS
|
||||
* @param string $filePath 本地文件路径
|
||||
* @param string $objectKey OSS上存储的对象名称
|
||||
* @return string 图片的 URL
|
||||
*/
|
||||
public function uploadImage($filePath, $objectKey)
|
||||
{
|
||||
try {
|
||||
// 上传文件到 OSS
|
||||
$this->ossClient->uploadFile($this->bucket, $objectKey, $filePath);
|
||||
// 构造图片的私有 URL
|
||||
$url = $this->generatePrivateUrl($objectKey);
|
||||
return $url;
|
||||
} catch (OssException $e) {
|
||||
// 错误处理
|
||||
throw new \RuntimeException('OSS Error: ' . $e->getMessage());
|
||||
} catch (\Throwable $th) {
|
||||
// 错误处理
|
||||
throw new BusinessException('Error: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图片的私有访问 URL
|
||||
* @param string $objectKey
|
||||
* @return string
|
||||
*/
|
||||
public function generatePrivateUrl($objectKey, $expireTime = 3600)
|
||||
{
|
||||
// 生成 OSS 私有 URL(有效期为 3600 秒)
|
||||
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace app\mzl\modules\wechat\service;
|
||||
|
||||
use EasyWeChat\MiniApp\Application as xcxApplication;
|
||||
use EasyWeChat\OfficialAccount\Application as gzhApplication;
|
||||
use support\exception\BusinessException;
|
||||
use support\Log;
|
||||
|
||||
class WechatService
|
||||
{
|
||||
|
||||
protected $config = [];
|
||||
|
||||
public function __construct(string $type)
|
||||
{
|
||||
$this->config = $this->loadConfig($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置
|
||||
*
|
||||
* @param string $type 类型
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function loadConfig(string $type): array
|
||||
{
|
||||
switch ($type) {
|
||||
case 'OfficialAccount':
|
||||
$data = [
|
||||
'app_id' => getenv('OFFICIALACCOUNT_APP_ID'),
|
||||
'secret' => getenv('OFFICIALACCOUNT_SECRET'),
|
||||
'oauth' => [
|
||||
'scopes' => getenv('OFFICIALACCOUNT_OAUTH_SCOPES')
|
||||
]
|
||||
];
|
||||
break;
|
||||
|
||||
case 'MiniApp':
|
||||
$data = [
|
||||
'app_id' => getenv('MINIAPP_APP_ID'),
|
||||
'secret' => getenv('MINIAPP_SECRET'),
|
||||
'response_type' => 'array',
|
||||
'log' => [
|
||||
'level' => 'debug',
|
||||
'file' => runtime_path().'/mzl/wechat.log',
|
||||
]
|
||||
];
|
||||
break;
|
||||
|
||||
case 'Pay':
|
||||
$data = [
|
||||
'mch_id' => getenv('MCH_ID'),
|
||||
'private_key' => base_path() . getenv('PRIVATE_KEY'),
|
||||
'certificate' => base_path() . getenv('CERTIFICATE'),
|
||||
'secret_key' => getenv('SECRET_KEY'),
|
||||
'v2_secret_key' => getenv('V2_SECRET_KEY'),
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BusinessException('不支持的类型');
|
||||
break;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
// === === === === === === 小程序相关方法 === === === === === ===
|
||||
|
||||
/**
|
||||
* 小程序获取openid
|
||||
* @param string $code
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function xcxOpenid(string $code): array
|
||||
{
|
||||
// 创建微信小程序对象
|
||||
$wechat = new xcxApplication($this->config);
|
||||
// 获取 session_key 和 openid
|
||||
return $wechat->getUtils()->codeToSession($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序获取手机号
|
||||
* @param string $phone_code
|
||||
* @return mixed
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function xcxPhoneNumber(string $phone_code)
|
||||
{
|
||||
// 创建微信小程序对象
|
||||
$wechat = new xcxApplication($this->config);
|
||||
// getPhoneNumber
|
||||
$data = ['code' => $phone_code];
|
||||
$result = $wechat->getClient()->postJson('wxa/business/getuserphonenumber', $data);
|
||||
if (isset($result['phone_info'])) {
|
||||
// 业务处理 $result['phone_info']['phoneNumber']
|
||||
return $result;
|
||||
} else {
|
||||
throw new BusinessException($result['errmsg']);
|
||||
}
|
||||
}
|
||||
|
||||
// === === === === === === 公众号相关方法 === === === === === ===
|
||||
|
||||
/**
|
||||
* 公众号获取openid
|
||||
* @param string $code
|
||||
* @return mixed
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function gzhOpenid(string $code)
|
||||
{
|
||||
$app = new gzhApplication($this->config);
|
||||
$app->getClient();
|
||||
$oauth = $app->getOAuth();
|
||||
return $oauth->userFromCode($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公众号发送模板消息
|
||||
* @param string $openid
|
||||
* @param string $cagetory
|
||||
* @param string $area
|
||||
* @param string $time
|
||||
* @param int $city_id
|
||||
* @param int $category_id
|
||||
* @return bool
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function gzhSendNotice(string $openid, string $cagetory, string $area, string $time, $city_id, $category_id)
|
||||
{
|
||||
$wechat = new gzhApplication($this->config);
|
||||
$token = $wechat->getAccessToken()->getToken();
|
||||
$template = [
|
||||
'touser' => $openid,
|
||||
'template_id' => 'NKThzslzC2XabRjfrq5M-TQO1zILJts5gklQlwvG1W8',
|
||||
"miniprogram" => [
|
||||
"appid" => "wxdf4bb512456d3463",
|
||||
"pagepath" => "pages/index/index?category_id=$category_id&city_id=$city_id&city_name=$area"
|
||||
],
|
||||
'data' => [
|
||||
'thing3' => ['value' => $cagetory],
|
||||
'thing23' => ['value' => $area],
|
||||
'time25' => ['value' => $time]
|
||||
]
|
||||
];
|
||||
$url = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=".$token;
|
||||
$result = $wechat->getClient()->postJson($url, $template);
|
||||
if ($result['errcode'] == 0) {
|
||||
return true;
|
||||
}else{
|
||||
Log::warning($result->getContent());
|
||||
throw new BusinessException($result->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公众号获取用户信息
|
||||
* @param string $openid
|
||||
* @return string
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function gzhUserinfo(string $openid)
|
||||
{
|
||||
$wechat = new gzhApplication($this->config);
|
||||
$token = $wechat->getAccessToken()->getToken();
|
||||
$url = "https://api.weixin.qq.com/cgi-bin/user/info?access_token=$token&openid=$openid&lang=zh_CN";
|
||||
$result = $wechat->getClient()->get($url);
|
||||
return $result->getContent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user