feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+120 -41
View File
@@ -10,7 +10,10 @@ namespace ywxapp\service;
use think\Exception;
use think\facade\Config;
use think\facade\Event;
use think\facade\Log;
use think\file\UploadedFile;
use ywxapp\model\CommonAttachment;
use ywxapp\utils\storage\StorageInterface;
/**
@@ -63,37 +66,109 @@ class FileStorageService
/**
* 上传文件(对外调用的方法)
*
* 提供两个事件扩展点:
* - FileUpload 前置分发事件:插件监听并返回结果数组即接管上传(OSS / FTP / 自定义存储)
* - FileUploaded 后置通知事件:上传成功后触发,供缩略图、水印、审计等扩展
*
* @param UploadedFile $file 上传的文件对象
* @param string $path 存储路径,留空自动生成
* @param array $options ['module' => 模块标识, 'rules' => 自定义校验规则(空数组=不校验)]
* @return array
*/
public function upload(UploadedFile $file, string $path = '', array $options = []): array
{
// 1. 基础验证(例如文件大小、类型)
// 1. 基础验证(文件大小、类型,可用 options.rules 自定义)
$this->validateFile($file, $options);
// 2. 如果未指定路径,生成默认路径
// 2. 未指定路径时生成默认路径
if (empty($path)) {
$path = $this->generatePath($file);
}
// throw new \think\Exception(json_encode( [
// $file->getoriginalName(), //获取文件原始名称带后缀
// $file->getSize(), //获取文件大小
// $file->extension(), //获取文件后缀
// ])
// , 403);
// 3. 调用具体驱动的上传方法
$data = [
// 3. 前置事件 FileUpload:插件可接管上传,返回结果数组即接管成功
$handled = Event::trigger('FileUpload', [
'file' => $file,
'path' => $path,
'options' => $options,
], true);
if ($handled) {
$data = $this->buildMeta($file, $handled, $options);
return $this->finalize($data, $options);
}
// 4. 调用内置存储驱动上传
list($name, $path, $url) = $this->storage->upload($file, $path, $options);
$data = $this->buildMeta($file, [
'name' => $name,
'path' => $path,
'url' => $url,
'storage' => $this->config['default'] ?? self::DRIVER_LOCAL,
], $options);
// 5. 写入附件记录 + 后置事件 FileUploaded
return $this->finalize($data, $options);
}
/**
* 上传收尾:写入附件表记录、触发 FileUploaded 事件、返回带 id 的数据
*/
protected function finalize(array $data, array $options): array
{
$data = $this->recordAttachment($data, $options);
Event::trigger('FileUploaded', $data);
return $data;
}
/**
* 写入附件表记录(落库失败不影响上传主流程)
*/
protected function recordAttachment(array $data, array $options): array
{
try {
$row = CommonAttachment::create([
'uid' => (int) ($options['uid'] ?? 0),
'module' => (string) ($data['module'] ?? 'index'),
'path' => (string) ($data['path'] ?? ''),
'url' => (string) ($data['url'] ?? ''),
'original' => (string) ($data['original'] ?? ''),
'name' => (string) ($data['name'] ?? ''),
'size' => (int) ($data['size'] ?? 0),
'ext' => (string) ($data['ext'] ?? ''),
'mime' => (string) ($data['mime'] ?? ''),
'storage' => (string) ($data['storage'] ?? self::DRIVER_LOCAL),
'driver_info' => $data['driver_info'] ?? [],
'is_image' => (int) ($data['is_image'] ?? 0),
'width' => (int) ($data['width'] ?? 0),
'height' => (int) ($data['height'] ?? 0),
'upload_ip' => (string) ($data['upload_ip'] ?? ''),
]);
$data['id'] = (int) $row->id;
} catch (\Throwable $e) {
Log::error('附件落库失败: ' . $e->getMessage());
}
return $data;
}
/**
* 组装上传元数据
*
* @param UploadedFile $file 上传的文件对象
* @param array $extra 附加数据(name / path / url / storage 等),覆盖默认元数据
* @param array $options 上传选项
* @return array
*/
protected function buildMeta(UploadedFile $file, array $extra, array $options): array
{
$data = [
'module' => $options['module'] ?? 'index', // 模块标识
'original' => $file->getoriginalName(),
'original' => $file->getOriginalName(),
'size' => $file->getSize(),
'ext' => $file->extension(),
'mime' => $file->getOriginalMime(),
'mime' => $file->getMime(),
'storage' => $this->config['default'] ?? self::DRIVER_LOCAL,
'is_image' => (int) str_starts_with($file->getMime(), 'image/'),
'upload_ip' => request()->ip(),
];
// 如果是图片且能获取到尺寸
// 图片时尝试获取尺寸
if ($data['is_image'] && function_exists('getimagesize')) {
// 注意:如果是云存储,这里可能需要从临时文件获取尺寸
$tempPath = $file->getRealPath();
@@ -103,21 +178,30 @@ class FileStorageService
$data['height'] = $imgInfo[1] ?? 0;
}
}
list($name, $path, $url) = $this->storage->upload($file, $path, $options);
$data['name'] = $name;
$data['path'] = $path;
$data['url'] = $url;
return $data;
return array_merge($data, $extra);
}
/**
* 删除文件
*
* 前置事件 FileDelete:插件监听并返回 true 即接管物理删除(OSS / FTP 删远端文件);
* 无监听时走内置存储驱动。删除成功触发 FileDeleted 事件。
*/
public function delete(string $path): bool
public function delete(string $path, array $options = []): bool
{
return $this->storage->delete($path);
$handled = Event::trigger('FileDelete', [
'path' => $path,
'options' => $options,
], true);
if ($handled) {
Event::trigger('FileDeleted', ['path' => $path, 'handled' => true]);
return true;
}
$ok = $this->storage->delete($path);
if ($ok) {
Event::trigger('FileDeleted', ['path' => $path, 'handled' => false]);
}
return $ok;
}
/**
@@ -129,31 +213,26 @@ class FileStorageService
}
/**
* 基础文件验证
* 基础文件验证,可用 options.rules 自定义规则,空数组表示不校验
*/
protected function validateFile(UploadedFile $file, array $options): void
{
// 使用验证器验证上传的文件
$rules = $options['rules'] ?? [
// 限制文件大小(单位b),这里限制为4M
'fileSize' => 4 * 1024 * 1024,
// 限制文件后缀,多个后缀以英文逗号分割
'fileExt' => 'gif,jpg,png',
];
if (empty($rules)) {
return;
}
validate(
[
'file' => [
// 限制文件大小(单位b),这里限制为4M
'fileSize' => 4 * 1024 * 1024,
// 限制文件后缀,多个后缀以英文逗号分割
'fileExt' => 'gif,jpg,png',
// fileSize 上传文件的最大字节;
// fileExt 文件后缀,多个用逗号分割或者数组;
// fileMime 文件MIME类型,多个用逗号分割或者数组;
// image 验证图像文件的尺寸和类型,
],
],
['file' => $rules],
[
'file.fileSize' => '文件太大',
'file.fileExt' => '不支持的文件后缀',
]
)->check(['file' => $file]);
}
/**