Initial commit

This commit is contained in:
18338781771
2025-05-14 13:19:23 +08:00
commit 1ab607f65a
313 changed files with 27595 additions and 0 deletions
+29
View File
@@ -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('删除失败');
}
}
+134
View File
@@ -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;
}
}
+198
View File
@@ -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;
}
}
+257
View File
@@ -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);
}
}
+76
View File
@@ -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);
}
}
+65
View File
@@ -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);
}
}
+584
View File
@@ -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;
}
}
+59
View File
@@ -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);
}
}
+57
View File
@@ -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;
}
}
+168
View File
@@ -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());
}
}
}
+56
View File
@@ -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);
}
}
}
+31
View File
@@ -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');
}
}
+66
View File
@@ -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');
}
}
+84
View File
@@ -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();
}
}