This commit is contained in:
youda 2025-04-17 14:35:10 +08:00
parent aa030e770d
commit 87a37460bd
15 changed files with 842 additions and 327 deletions

3
.gitignore vendored
View File

@ -4,4 +4,5 @@ runtime/*
vendor/*
404.html
public/.well-known/*
.env
.env
public/ueditor/*

View File

@ -87,4 +87,30 @@ class Danye extends Base
}
}
/**
* 更改图片优化状态
* @return \think\response\Json
*/
public function change_image_optimizer()
{
$id = input('post.id/d', 0);
$status = input('post.status/d', 0);
if (empty($id)) {
return $this->renderError('参数错误');
}
$info = DanyeModel::getInfo(['id' => $id], 'id');
if (!$info) {
return $this->renderError('数据不存在');
}
$result = DanyeModel::where(['id' => $id])->update(['is_image_optimizer' => $status, 'update_time' => time()]);
if ($result) {
return $this->renderSuccess('操作成功');
} else {
return $this->renderError('操作失败');
}
}
}

View File

@ -229,6 +229,7 @@ Route::rule('send_order_dandufahuo', 'Order/send_order_dandufahuo');
Route::rule('danye', 'Danye/index', 'GET|POST');
Route::rule('danye_edit', 'Danye/edit', 'GET|POST');
Route::rule('gonggao', 'Danye/gonggao', 'GET|POST');
Route::rule('change_image_optimizer', 'Danye/change_image_optimizer', 'POST');
#============================
#Advert.php轮播图

View File

@ -18,6 +18,13 @@
condition="$info['id'] lt 20" }readonly{/if}>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">图片优化</label>
<div class="layui-input-block">
<input type="checkbox" name="is_image_optimizer" value="1" lay-skin="switch" lay-text="开启|关闭" {if condition="$info['is_image_optimizer'] eq 1"}checked{/if}>
<div class="layui-word-aux">开启后将只显示图片,同时图片可以长按识别</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">内容</label>
<div class="layui-input-block">
@ -48,6 +55,8 @@
<script type="text/javascript">
layui.use(['layer', 'form'], function () {
var $ = layui.$;
var form = layui.form;
$(function () {
// window.UEDITOR_CONFIG = {
// // 其他配置...
@ -60,6 +69,8 @@
// };
var ue = UE.getEditor('editor');
// 初始化表单
form.render();
})
});

View File

@ -15,6 +15,13 @@
<input type="text" name="title" value="{$info['title']}" lay-verify="required" placeholder="请输入标题" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">图片优化</label>
<div class="layui-input-block">
<input type="checkbox" name="is_image_optimizer" value="1" lay-skin="switch" lay-text="开启|关闭" {if condition="$info['is_image_optimizer'] eq 1"}checked{/if}>
<div class="layui-word-aux">开启后将只显示图片,同时图片可以长按识别</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">内容</label>
<div class="layui-input-block" >
@ -42,8 +49,13 @@
<script type="text/javascript">
layui.use(['layer','form'], function(){
var $ = layui.$;
var form = layui.form;
$(function(){
var ue = UE.getEditor('editor');
// 初始化表单
form.render();
})
});

View File

@ -5,34 +5,41 @@
<div class="layui-card-body">
<xblock>
<div style="padding-bottom: 10px;">
<span style="line-height:40px;float:left;">图片优化:开启后将只显示图片,同时图片可以长按识别,建议带二维码的图片开启。</span>
<span style="line-height:40px;float:right;">共有数据: {$count}条</span>
</div>
</xblock>
<table class="layui-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="data" id="vo"}
<tr>
<td>{$vo['id']}</td>
<td>{$vo['title']}</td>
<td >
<a style="text-decoration:none" title="编辑" onclick="danye_edit({$vo.id})" class="layui-btn layui-btn-xs">
<i class="layui-icon layui-icon-edit"></i>编辑
</a>
</td>
</tr>
{/volist}
{if condition="$count eq 0"}
<tr><td colspan='3' style="text-align:center;">暂时没有数据!</td></tr>
{/if}
</tbody>
</table>
<form class="layui-form">
<table class="layui-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>图片优化</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="data" id="vo"}
<tr>
<td>{$vo['id']}</td>
<td>{$vo['title']}</td>
<td>
<input type="checkbox" name="switch" lay-skin="switch" lay-filter="is_image_optimizer" value="{$vo.id}" lay-text="开启|关闭" {if condition="$vo['is_image_optimizer'] eq 1"}checked{/if}>
</td>
<td >
<a style="text-decoration:none" title="编辑" onclick="danye_edit({$vo.id})" class="layui-btn layui-btn-xs">
<i class="layui-icon layui-icon-edit"></i>编辑
</a>
</td>
</tr>
{/volist}
{if condition="$count eq 0"}
<tr><td colspan='4' style="text-align:center;">暂时没有数据!</td></tr>
{/if}
</tbody>
</table>
</form>
<div class="layui-box layui-laypage layui-laypage-default">
{$page|raw}
</div>
@ -41,8 +48,41 @@
</div>
{include file="Public:footer"/}
<script type="text/javascript">
layui.use(['table'], function(){
layui.use(['table', 'form', 'jquery'], function(){
var form = layui.form;
var $ = layui.jquery;
// 必须要初始化表单,否则开关不会显示
form.render();
// 监听开关事件
form.on('switch(is_image_optimizer)', function(obj){
var id = this.value;
var status = obj.elem.checked ? 1 : 0;
// 发送AJAX请求更新状态
$.ajax({
url: "{:url('/admin/change_image_optimizer')}",
type: 'POST',
data: {id: id, status: status},
success: function(res){
if(res.status == 1){
layer.msg(res.msg, {icon: 1});
}else{
layer.msg(res.msg, {icon: 2});
// 如果更新失败,恢复开关状态
obj.elem.checked = !obj.elem.checked;
form.render();
}
},
error: function(){
layer.msg('网络错误', {icon: 2});
// 如果请求失败,恢复开关状态
obj.elem.checked = !obj.elem.checked;
form.render();
}
});
});
});

View File

@ -24,6 +24,7 @@ use app\common\model\UserCoupon;
use app\common\model\GoodsType;
use app\common\service\CommonService;
use app\common\model\GoodsExtend;
use think\Collection;
class Goods extends Base
{
@ -61,36 +62,35 @@ class Goods extends Base
$paginate = 15;
$type_str = request()->param('type', -1);
// 1一番赏 2无限赏 3擂台赏 4抽卡机 5积分赏 6全局赏 7福利盲盒 8领主赏 9连击赏 10 商品赏
if ($type_str == 1) {
$whe[] = ['type', '=', 1];
} elseif ($type_str == 2) {
$whe[] = ['type', '=', 2];
} elseif ($type_str == 3) {
$whe[] = ['type', '=', 3];
} elseif ($type_str == 5) {
$whe[] = ['type', '=', 5];
} elseif ($type_str == 6) {
$whe[] = ['type', '=', 6];
} elseif ($type_str == 7) {
$whe[] = ['type', '=', 7];
} elseif ($type_str == 8) {
$whe[] = ['type', '=', 8];
} elseif ($type_str == 9) {
$whe[] = ['type', 'in', [9]];
} elseif ($type_str == 10) {
$paginate = 999;
$whe[] = ['type', '=', 10];
} elseif ($type_str == 11) {
$whe[] = ['type', '=', 11];
} elseif ($type_str == 12) {
$whe[] = ['type', '=', 12];
} elseif ($type_str == 15) {
$whe[] = ['type', '=', 15];
} elseif ($type_str == 16) {
$whe[] = ['type', '=', 16];
// 使用映射数组简化类型条件判断
$typeMapping = [
1 => ['type' => 1],
2 => ['type' => 2],
3 => ['type' => 3],
5 => ['type' => 5],
6 => ['type' => 6],
7 => ['type' => 7],
8 => ['type' => 8],
9 => ['type' => 'in', 'value' => [9]],
10 => ['type' => 10, 'paginate' => 999],
11 => ['type' => 11],
12 => ['type' => 12],
15 => ['type' => 15],
16 => ['type' => 16],
];
if (isset($typeMapping[$type_str])) {
if (isset($typeMapping[$type_str]['paginate'])) {
$paginate = $typeMapping[$type_str]['paginate'];
}
if (isset($typeMapping[$type_str]['type']) && $typeMapping[$type_str]['type'] === 'in') {
$whe[] = ['type', 'in', $typeMapping[$type_str]['value']];
} else {
$whe[] = ['type', '=', $typeMapping[$type_str]['type']];
}
} else {
// $whe[] = ['type', 'not in', [4, 10, 15]];
$whe[] = ['type', 'in', [2, 6, 8, 16]];
}
@ -123,80 +123,108 @@ class Goods extends Base
#盒子
$goods = GoodsModel::where($whe)
->field("id,title,imgurl,price,type,stock,sale_stock,status,lock_is,is_shou_zhe,new_is")
->order("sort desc,id desc")->paginate($paginate)->each(function ($itme) {
$itme['imgurl'] = imageUrl($itme['imgurl']);
#剩余
$itme['sale_stock'] = $itme['stock'] - $itme['sale_stock'];
if ($itme['type'] == 10) {
$goods_id = $itme['id'];
#本箱子余量
$goodslist = GoodsList::field('sum(`stock`) as stock, sum(`surplus_stock`) as surplus_stock')
->where('goods_id', '=', $goods_id)
->where('num', '=', 1)
->where('shang_id', 'between', self::$shang_prize_id)
->find();
$stock1 = intval($goodslist['stock']);
$surplus_stock1 = intval($goodslist['surplus_stock']);
//库存-剩余库存
// $surplus_stock =$stock1 - $surplus_stock1 ;
$itme['sale_stock'] = $surplus_stock1;
$itme['stock'] = $stock1;
}
#参与次数
$join_count = OrderList::field('id')
->where('shang_id', 'between', self::$shang_count_id)
->where('goods_id', '=', $itme['id'])
->where('parent_goods_list_id', '=', 0)
->where('order_type', '=', $itme['type'])
->count();
$itme['join_count'] = $join_count;
$itme['need_draw_num'] = 0;
if ($itme['type'] == 7) {
$itme['need_draw_num'] = 1;
}
$type_text = '';
->order("sort desc,id desc")
->paginate($paginate);
if ($itme['type'] == 1) {
$type_text = '一番赏';
} elseif ($itme['type'] == 2) {
$type_text = '无限赏';
} elseif ($itme['type'] == 3) {
$type_text = '擂台赏';
} elseif ($itme['type'] == 5) {
$type_text = '积分赏';
} elseif ($itme['type'] == 6) {
$type_text = '全局赏';
} elseif ($itme['type'] == 8) {
$type_text = '领主赏';
} elseif ($itme['type'] == 9) {
$type_text = '连击赏';
} elseif ($itme['type'] == 10) {
$type_text = '商品赏';
} elseif ($itme['type'] == 11) {
$type_text = '自制赏';
} elseif ($itme['type'] == 16) {
$type_text = '翻倍赏';
}
// elseif ($itme['type'] == 9) {
// $type_text = '冲冲赏';
// }
$itme['type_text'] = $type_text;
});
//循环盒子内容,将角标文字写入到$itme['corner_text']中
foreach ($goods->items() as &$itme) {
//根据$itme['type']值找到对应的角标文字goods_types_arr 是数组value,corner_text
//$goods_types_arr 查找对应的value
if (isset($goods_types_map[$itme['type']])) {
$corner_text = $goods_types_map[$itme['type']];
$itme['type_text'] = $corner_text;
// 获取所有商品项
$goodsItems = $goods->items();
// 如果没有商品,直接返回
if (empty($goodsItems)) {
$new_data = [
'data' => [],
'last_page' => $goods->lastPage(),
];
return $this->renderSuccess('请求成功', $new_data);
}
// 获取所有商品ID和类型
$goodsIds = [];
$goodsTypes = [];
$type10GoodsIds = [];
foreach ($goodsItems as $item) {
$goodsIds[] = $item['id'];
$goodsTypes[$item['id']] = $item['type'];
// 记录type=10的商品ID
if ($item['type'] == 10) {
$type10GoodsIds[] = $item['id'];
}
}
// 批量查询所有商品的参与次数
$joinCountMap = [];
if (!empty($goodsIds)) {
$joinCounts = OrderList::field('goods_id, order_type, COUNT(1) as count')
->where('shang_id', 'between', self::$shang_count_id)
->where('goods_id', 'in', $goodsIds)
->where('parent_goods_list_id', '=', 0)
->group('goods_id, order_type')
->select()
->toArray();
// 转为映射关系,键为"goods_id_order_type"格式
foreach ($joinCounts as $joinCount) {
$key = $joinCount['goods_id'] . '_' . $joinCount['order_type'];
$joinCountMap[$key] = $joinCount['count'];
}
}
// 批量查询type=10的商品库存
$goodslistMap = [];
if (!empty($type10GoodsIds)) {
$goodslists = GoodsList::field('goods_id, sum(`stock`) as stock, sum(`surplus_stock`) as surplus_stock')
->where('goods_id', 'in', $type10GoodsIds)
->where('num', '=', 1)
->where('shang_id', 'between', self::$shang_prize_id)
->group('goods_id')
->select()
->toArray();
foreach ($goodslists as $goodslist) {
$goodslistMap[$goodslist['goods_id']] = [
'stock' => intval($goodslist['stock']),
'surplus_stock' => intval($goodslist['surplus_stock'])
];
}
}
// 处理所有商品数据
foreach ($goodsItems as &$item) {
$item['imgurl'] = imageUrl($item['imgurl']);
// 计算剩余库存
$item['sale_stock'] = $item['stock'] - $item['sale_stock'];
// 处理type=10的商品
if ($item['type'] == 10 && isset($goodslistMap[$item['id']])) {
$goodslistData = $goodslistMap[$item['id']];
$item['sale_stock'] = $goodslistData['surplus_stock'];
$item['stock'] = $goodslistData['stock'];
}
// 设置参与次数
$joinCountKey = $item['id'] . '_' . $item['type'];
$item['join_count'] = isset($joinCountMap[$joinCountKey]) ? $joinCountMap[$joinCountKey] : 0;
// 设置需要抽奖的数量
$item['need_draw_num'] = $item['type'] == 7 ? 1 : 0;
// 设置角标文字,合并之前的第二个循环逻辑
if (isset($goods_types_map[$item['type']])) {
$item['type_text'] = $goods_types_map[$item['type']];
}
}
// 将处理后的数据设置回分页对象
$goods->setCollection(new Collection($goodsItems));
$new_data = [
'data' => $goods->items(),
'last_page' => $goods->lastPage(),
];
return $this->renderSuccess('请求成功', $new_data);
}

View File

@ -76,14 +76,21 @@ class Index extends Base
*/
public function danye()
{
// 设置header
$type = \request()->param('type/d', 0);
$info = Danye::where(['id' => $type])->find();
$is_image_optimizer = 0;
if ($info) {
$content = contentUrl($info['content']);
$is_image_optimizer = $info['is_image_optimizer'];
// header('Access-Control-Allow-Headers: Content-Type');
} else {
$content = '';
}
return $this->renderSuccess("请求成功", $content);
// return $this->renderSuccess("请求成功", $content);
return json(['status' => 1, 'msg' => '请求成功', 'data' => $content, 'is_image_optimizer' => $is_image_optimizer]);
}
@ -114,7 +121,7 @@ class Index extends Base
{
$type = request()->param('type_id/d', 1);
#首页轮播图
$advert = Advert::field('imgurl,ttype,coupon_id,goods_id')->where(['type' => $type])->order('sort desc,id desc')->select();
$advert = Advert::field('imgurl,ttype,coupon_id,goods_id,url')->where(['type' => $type])->order('sort desc,id desc')->select();
foreach ($advert as &$advert_value) {
$advert_value['imgurl'] = imageUrl($advert_value['imgurl']);
}
@ -206,22 +213,142 @@ class Index extends Base
}
/**
* 生成带用户推广二维码的海报图片
* @return \think\response\Json|void
*/
public function generate_urllinks()
{
// 获取并验证用户ID
$userId = request()->param('userId/d', 0);
$wxServer = new \app\common\server\Wx($this->app);
$user_base = $wxServer->generateUrlLinks($userId);
$autoload = new \app\common\server\autoload();
$currentDir = getcwd();
if ($userId <= 0) {
return $this->renderError('无效的用户ID');
}
$imageData = $autoload->generatePosterWithQR($currentDir . '/img_poster.jpg', $user_base);
if ($imageData) {
// 检查用户是否存在
$user = User::find($userId);
if (!$user) {
return $this->renderError('用户不存在');
}
// 获取配置信息
$config = getConfig('base');
$posterPath = $config['poster_template'] ?? '/img_poster.jpg';
$cacheExpire = $config['poster_cache_expire'] ?? 86400; // 默认缓存1天
// 创建缓存目录结构用户ID分组避免单目录文件过多
$userGroup = floor($userId / 1000); // 每1000个用户一个目录
$cacheDir = runtime_path() . 'poster/' . $userGroup . '/';
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
// 缓存文件名基于用户ID和模板文件的哈希
$templateFile = getcwd() . $posterPath;
$templateHash = md5_file($templateFile); // 使用文件内容哈希而不是修改时间
$cacheFile = $cacheDir . 'user_' . $userId . '_' . $templateHash . '.png';
// 清理该用户的旧缓存文件(保留当前有效的)
$this->cleanUserPosterCache($cacheDir, $userId, $templateHash);
// 定期清理过期缓存每100次请求执行一次避免频繁IO
if (mt_rand(1, 100) === 1) {
$this->cleanExpiredPosterCache($cacheExpire);
}
// 如果缓存存在且未过期,直接使用缓存
if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < $cacheExpire)) {
$imageData = file_get_contents($cacheFile);
} else {
// 生成URL链接
$wxServer = new \app\common\server\Wx($this->app);
$user_base = $wxServer->generateUrlLinks($userId);
// 生成海报
$autoload = new \app\common\server\autoload();
$imageData = $autoload->generatePosterWithQR($templateFile, $user_base);
// 保存到缓存
if ($imageData) {
file_put_contents($cacheFile, $imageData);
// 更新最后访问时间文件(用于清理判断)
touch($cacheFile);
}
}
// 检查是否需要直接输出图片
$outputImage = request()->param('output/d', 1);
if ($imageData && $outputImage) {
header('Content-Type: image/png');
header('Content-Length: ' . strlen($imageData)); // 设置图像长度,帮助浏览器处理流式内容
header('Content-Length: ' . strlen($imageData));
echo $imageData;
exit();
} elseif ($imageData) {
// 生成可访问的URL使用相对路径更安全且灵活
$relativePath = 'runtime/poster/' . $userGroup . '/user_' . $userId . '_' . $templateHash . '.png';
return $this->renderSuccess('海报生成成功', [
'image_url' => request()->domain() . '/' . $relativePath
]);
} else {
// 生成失败,返回错误信息
return $this->renderError('海报生成失败');
}
}
/**
* 清理指定用户的旧海报缓存
* @param string $cacheDir 缓存目录
* @param int $userId 用户ID
* @param string $currentHash 当前模板哈希
*/
private function cleanUserPosterCache($cacheDir, $userId, $currentHash)
{
// 查找该用户的所有缓存文件
$pattern = $cacheDir . 'user_' . $userId . '_*.png';
$files = glob($pattern);
foreach ($files as $file) {
// 如果不是当前使用的缓存文件,则删除
if (strpos($file, 'user_' . $userId . '_' . $currentHash . '.png') === false) {
@unlink($file);
}
}
}
/**
* 清理所有过期的海报缓存
* @param int $expireTime 过期时间(秒)
*/
private function cleanExpiredPosterCache($expireTime)
{
// 获取缓存根目录
$rootCacheDir = runtime_path() . 'poster/';
if (!is_dir($rootCacheDir)) {
return;
}
// 当前时间
$now = time();
// 遍历所有用户组目录
$groupDirs = glob($rootCacheDir . '*/');
foreach ($groupDirs as $groupDir) {
// 获取组目录中的所有PNG文件
$files = glob($groupDir . '*.png');
foreach ($files as $file) {
// 如果文件过期(最后修改时间超过过期时间)
if ($now - filemtime($file) > $expireTime) {
@unlink($file);
}
}
// 如果目录为空,删除目录
$remainingFiles = glob($groupDir . '*');
if (empty($remainingFiles)) {
@rmdir($groupDir);
}
}
return $this->renderSuccess('请求成功', $user_base);
}
/**

View File

@ -3,7 +3,12 @@
return [
// 域名绑定检查
\app\api\middleware\DomainBind::class,
//h5跨域
\app\api\middleware\Allow::class,
// 跨域处理中间件
\app\api\middleware\CorsMiddleware::class,
// OPTIONS预检请求处理中间件
\app\api\middleware\OptionsRequestMiddleware::class,
// GET请求签名验证中间件
\app\api\middleware\SignatureVerifyMiddleware::class,
// 注意原来的Allow中间件已被拆分为上面三个专门的中间件不再需要
// \app\api\middleware\Allow::class,
];

View File

@ -1,191 +0,0 @@
<?php
/**
*
* User: anker
* Date: 4/22/22
* Email: <13408046898@163.com>
**/
namespace app\api\middleware;
use think\facade\Config;
use think\exception\HttpResponseException;
use think\Response;
class Allow
{
public function handle($request, \Closure $next)
{
// 处理跨域
header('Access-Control-Allow-Origin: *');
header('Access-Control-Max-Age: 1800');
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE');
header('Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With,access-token,token,adid,clickid,client');
// 第一次预请求不管
if (strtoupper($request->method()) == "OPTIONS") {
exit;
}
// 对GET请求进行签名验证
if (strtoupper($request->method()) == "GET") {
$this->verifySignature($request);
}
// 处理跨域
// 后置中间件
$response = $next($request);
return $response;
}
/**
* 验证请求签名
* @param \think\Request $request
* @return void
*/
protected function verifySignature($request)
{
// 获取所有GET参数
$params = $request->get();
// 获取当前请求路径
$path = $request->pathinfo();
// 白名单路径检查 - 不需要验证域名的路径
$whitelistPaths = $this->getWhitelistPaths();
foreach ($whitelistPaths as $whitePath) {
// 支持简单的路径匹配,如 'notify/*' 匹配所有通知路径
if ($this->pathMatch($whitePath, $path)) {
// 白名单路径,跳过域名检查
// \think\facade\Log::info('白名单路径访问: ' . $path . ', 域名: ' . $);
return;
}
}
// 如果请求中携带is_test=true参数则跳过签名验证
if (isset($params['is_test']) && $params['is_test'] === 'true') {
return;
}
// 检查是否有必要的签名参数
if (!isset($params['timestamp']) || !isset($params['sign'])) {
$this->error('缺少必要的签名参数');
}
// 检查请求时间戳是否在合理范围内例如5分钟内
if (time() - intval($params['timestamp']) > 300) {
$this->error('请求已过期');
}
// 从请求中获取签名
$requestSign = $params['sign'];
//移除url
unset($params['s']);
// 从参数中移除签名,用于生成本地签名
unset($params['sign']);
// 按照键名对参数进行排序
ksort($params);
// 组合参数为字符串
$signStr = '';
foreach ($params as $key => $value) {
$signStr .= $key . '=' . $value . '&';
}
// 获取当前请求的域名和时间戳,组合为密钥
$host = $request->host();
$timestamp = $params['timestamp'];
$appSecret = $host . $timestamp;
// 添加密钥
$signStr = rtrim($signStr, '&') . $appSecret;
// 生成本地签名使用MD5签名算法
$localSign = md5($signStr);
// 比对签名
if ($requestSign !== $localSign) {
$this->error('签名验证失败');
}
}
/**
* 返回错误信息
* @param string $msg 错误信息
* @param int $code 错误码
* @return void
*/
protected function error($msg, $code = 0)
{
$result = [
'status' => $code,
'msg' => $msg,
'data' => null
];
$response = Response::create($result, 'json', $code);
throw new HttpResponseException($response);
}
/**
* 中间件结束调度
* @param \think\Response $response
* @return void
*/
public function end(\think\Response $response)
{
}
/**
* 获取路径白名单
*
* @return array 白名单路径列表
*/
protected function getWhitelistPaths()
{
// 1. 默认白名单路径(如支付回调通知等)
$defaultWhitelist = [
'notify/*', // 支付回调等通知
'health', // 健康检查
'debug', // 调试接口
'generate_urllinks',
];
// 2. 从配置文件中获取白名单路径
try {
$configWhitelist = Config::get('api.whitelist_paths', []);
if (!empty($configWhitelist) && is_array($configWhitelist)) {
return array_merge($defaultWhitelist, $configWhitelist);
}
} catch (\Exception $e) {
\think\facade\Log::error('获取API白名单路径配置失败: ' . $e->getMessage());
}
return $defaultWhitelist;
}
/**
* 路径匹配检查
*
* @param string $pattern 白名单路径模式
* @param string $path 请求路径
* @return bool 是否匹配
*/
protected function pathMatch($pattern, $path)
{
// 完全匹配
if ($pattern === $path) {
return true;
}
// 通配符匹配 (例如: 'notify/*')
if (strpos($pattern, '*') !== false) {
$pattern = str_replace('*', '.*', $pattern);
$pattern = '/^' . str_replace('/', '\/', $pattern) . '$/i';
return preg_match($pattern, $path);
}
return false;
}
}

View File

@ -0,0 +1,44 @@
<?php
/**
* 处理跨域请求的中间件
*
* User: system
* Date: 2024/06/18
**/
namespace app\api\middleware;
class CorsMiddleware
{
/**
* 处理跨域请求
*
* @param \think\Request $request
* @param \Closure $next
* @return \think\Response
*/
public function handle($request, \Closure $next)
{
// 处理跨域
header('Access-Control-Allow-Origin: *');
header('Access-Control-Max-Age: 1800');
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE');
header('Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With,access-token,token,adid,clickid,client');
// 继续执行下一个中间件
$response = $next($request);
return $response;
}
/**
* 中间件结束调度
*
* @param \think\Response $response
* @return void
*/
public function end(\think\Response $response)
{
// 可以在这里对响应做额外处理
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* 处理OPTIONS预检请求的中间件
*
* User: system
* Date: 2024/06/18
**/
namespace app\api\middleware;
class OptionsRequestMiddleware
{
/**
* 处理OPTIONS预检请求
*
* @param \think\Request $request
* @param \Closure $next
* @return \think\Response
*/
public function handle($request, \Closure $next)
{
// 处理OPTIONS预检请求
if (strtoupper($request->method()) == "OPTIONS") {
exit;
}
// 继续执行下一个中间件
$response = $next($request);
return $response;
}
/**
* 中间件结束调度
*
* @param \think\Response $response
* @return void
*/
public function end(\think\Response $response)
{
// 可以在这里对响应做额外处理
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace app\api\middleware;
use think\Request;
use think\Response;
use think\exception\HttpResponseException;
use app\common\server\RedisHelper;
class RateLimit
{
// 默认配置每60秒最多20次
protected $limit = 20;
protected $ttl = 60;
/**
* 限流处理入口
* @param Request $request
* @param \Closure $next
* @param string $keyPrefix 可传入 'ip' 'token' 等标识字段
* @return Response
*/
public function handle($request, \Closure $next, $keyPrefix = 'ip')
{
$identifier = $this->getIdentifier($request, $keyPrefix);
$redisKey = "rate_limit:{$keyPrefix}:" . $identifier;
$redis = (new RedisHelper())->getRedis();
$count = $redis->incr($redisKey);
if ($count == 1) {
// 第一次设置过期时间
$redis->expire($redisKey, $this->ttl);
}
if ($count > $this->limit) {
$this->error("请求频率过高,请稍后再试");
}
return $next($request);
}
/**
* 获取请求标识符支持IP/token
*/
protected function getIdentifier(Request $request, string $keyPrefix)
{
switch ($keyPrefix) {
case 'token':
return $request->header('token') ?? $request->param('token') ?? 'guest';
case 'ip':
default:
return $request->ip();
}
}
/**
* 错误输出统一格式
*/
protected function error($msg, $code = 429)
{
$result = [
'status' => 0,
'msg' => $msg,
'data' => null
];
$response = Response::create($result, 'json', $code);
throw new HttpResponseException($response);
}
}

View File

@ -0,0 +1,291 @@
<?php
/**
* 请求签名验证中间件
*
* User: system
* Date: 2024/06/18
* Update: 2024/06/19 - 增加POST请求签名验证和防重放攻击功能
**/
namespace app\api\middleware;
use think\facade\Config;
use think\exception\HttpResponseException;
use think\Response;
class SignatureVerifyMiddleware
{
/**
* Redis实例
* @var \Redis
*/
protected $redis;
/**
* Redis键前缀
*/
const REDIS_KEY_PREFIX = 'api_nonce_';
/**
* Nonce过期时间
*/
const NONCE_EXPIRE_TIME = 600; // 10分钟
/**
* 时间戳允许的误差(秒)
*/
const TIMESTAMP_TOLERANCE = 60; // 1分钟
/**
* 构造函数初始化Redis连接
*/
public function __construct()
{
$this->redis = (new \app\common\server\RedisHelper())->getRedis();
}
/**
* 处理请求签名验证
*
* @param \think\Request $request
* @param \Closure $next
* @return \think\Response
*/
public function handle($request, \Closure $next)
{
// 获取当前请求路径
$path = $request->pathinfo();
// 检查是否在白名单内
if ($this->isWhitelistedPath($path, $request)) {
return $next($request);
}
// 根据请求方法进行签名验证
$method = strtoupper($request->method());
if ($method == "GET") {
$this->verifySignature($request, $request->get());
} elseif ($method == "POST") {
$this->verifySignature($request, $request->post());
}
// 继续执行下一个中间件
$response = $next($request);
return $response;
}
/**
* 检查请求路径是否在白名单中
*
* @param string $path 请求路径
* @param \think\Request $request 请求对象
* @return bool 是否在白名单中
*/
protected function isWhitelistedPath($path, $request)
{
// 检查是否有内部标识
$params = $request->param();
if (isset($params['is_test']) && $params['is_test'] === 'true') {
return true;
}
// 获取白名单路径
$whitelistPaths = $this->getWhitelistPaths();
// 检查路径是否在白名单内
foreach ($whitelistPaths as $whitePath) {
if ($this->pathMatch($whitePath, $path)) {
return true;
}
}
// 检查IP白名单
$ipWhitelist = $this->getIpWhitelist();
$clientIp = $request->ip();
if (in_array($clientIp, $ipWhitelist)) {
return true;
}
return false;
}
/**
* 验证请求签名
* @param \think\Request $request 请求对象
* @param array $params 请求参数
* @return void
*/
protected function verifySignature($request, $params)
{
// 检查是否有必要的签名参数
if (!isset($params['timestamp']) || !isset($params['sign']) || !isset($params['nonce'])) {
$this->error('缺少必要的签名参数');
}
// 检查时间戳是否在允许范围内1分钟误差
$timestamp = intval($params['timestamp']);
$now = time();
if (abs($now - $timestamp) > self::TIMESTAMP_TOLERANCE) {
$this->error('请求时间戳超出允许范围');
}
// 检查nonce是否被使用过防重放攻击
$nonce = $params['nonce'];
$nonceKey = self::REDIS_KEY_PREFIX . $nonce;
if ($this->redis->exists($nonceKey)) {
$this->error('无效的请求nonce已被使用');
}
// 记录nonce到Redis有效期10分钟足够覆盖时间戳可接受的误差范围
$this->redis->setex($nonceKey, self::NONCE_EXPIRE_TIME, $timestamp);
// 从请求中获取签名
$requestSign = $params['sign'];
// 拷贝参数,移除不需要的参数
$signParams = $params;
unset($signParams['s']); // 移除URL参数
unset($signParams['sign']); // 移除签名参数
// 按照键名对参数进行排序
ksort($signParams);
// 组合参数为字符串
$signStr = '';
foreach ($signParams as $key => $value) {
// 处理数组或对象类型的参数
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
$signStr .= $key . '=' . $value . '&';
}
// 获取当前请求的域名和时间戳,组合为密钥
$host = $request->host();
$timestamp = $signParams['timestamp'];
$appSecret = $host . $timestamp;
// 添加密钥
$signStr = rtrim($signStr, '&') . $appSecret;
// 生成本地签名使用MD5签名算法
$localSign = md5($signStr);
// 比对签名
if ($requestSign !== $localSign) {
$this->error('签名验证失败');
}
}
/**
* 返回错误信息
* @param string $msg 错误信息
* @param int $code 错误码
* @return void
*/
protected function error($msg, $code = 0)
{
$result = [
'status' => $code,
'msg' => $msg,
'data' => null
];
$response = Response::create($result, 'json', $code);
throw new HttpResponseException($response);
}
/**
* 获取路径白名单
*
* @return array 白名单路径列表
*/
protected function getWhitelistPaths()
{
// 1. 默认白名单路径(如支付回调通知等)
$defaultWhitelist = [
'notify/*', // 支付回调等通知
'health', // 健康检查
'debug', // 调试接口
'generate_urllinks',
'webhook/*', // 添加webhook路径
'internal/*', // 内部接口
];
// 2. 从配置文件中获取白名单路径
try {
$configWhitelist = Config::get('api.whitelist_paths', []);
if (!empty($configWhitelist) && is_array($configWhitelist)) {
return array_merge($defaultWhitelist, $configWhitelist);
}
} catch (\Exception $e) {
\think\facade\Log::error('获取API白名单路径配置失败: ' . $e->getMessage());
}
return $defaultWhitelist;
}
/**
* 获取IP白名单
*
* @return array IP白名单列表
*/
protected function getIpWhitelist()
{
// 默认IP白名单
$defaultIpWhitelist = [
'127.0.0.1', // 本地回环地址
'::1', // IPv6本地回环地址
];
// 从配置文件中获取IP白名单
try {
$configIpWhitelist = Config::get('api.ip_whitelist', []);
if (!empty($configIpWhitelist) && is_array($configIpWhitelist)) {
return array_merge($defaultIpWhitelist, $configIpWhitelist);
}
} catch (\Exception $e) {
\think\facade\Log::error('获取API白名单IP配置失败: ' . $e->getMessage());
}
return $defaultIpWhitelist;
}
/**
* 路径匹配检查
*
* @param string $pattern 白名单路径模式
* @param string $path 请求路径
* @return bool 是否匹配
*/
protected function pathMatch($pattern, $path)
{
// 完全匹配
if ($pattern === $path) {
return true;
}
// 通配符匹配 (例如: 'notify/*')
if (strpos($pattern, '*') !== false) {
$pattern = str_replace('*', '.*', $pattern);
$pattern = '/^' . str_replace('/', '\/', $pattern) . '$/i';
return preg_match($pattern, $path);
}
return false;
}
/**
* 中间件结束调度
*
* @param \think\Response $response
* @return void
*/
public function end(\think\Response $response)
{
// 可以在这里对响应做额外处理
}
}

View File

@ -28,4 +28,11 @@ return [
'debug', // 调试接口
'wechat/callback', // 微信回调
],
// IP白名单 - 不需要进行签名验证的IP地址
'ip_whitelist' => [
'127.0.0.1', // 本地测试
'::1', // IPv6本地
'192.168.0.1', // 内网测试服务器
'10.0.0.1', // 内网开发服务器
],
];