安全(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(市场 / 开发者中心 / 审核 / 分润)
236 lines
7.2 KiB
PHP
236 lines
7.2 KiB
PHP
<?php
|
|
// +----------------------------------------------------------------------
|
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
|
// +----------------------------------------------------------------------
|
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
|
// +----------------------------------------------------------------------
|
|
// | Author: ywxapp<admin@ywxapp.cn>
|
|
// +----------------------------------------------------------------------
|
|
declare (strict_types = 1);
|
|
|
|
namespace addon\docs\controller\backend;
|
|
|
|
use addon\docs\model\DocsDoc;
|
|
use addon\docs\model\DocsProject;
|
|
use addon\docs\model\DocsVersion;
|
|
use addon\docs\service\DocSearchService;
|
|
use addon\docs\service\DocTreeService;
|
|
use think\facade\Db;
|
|
use ywxapp\controller\BackendBase;
|
|
|
|
/**
|
|
* 文档项目管理
|
|
*
|
|
* @author ywxapp <admin@ywxapp.cn>
|
|
*/
|
|
class Project extends BackendBase
|
|
{
|
|
/**
|
|
* 项目列表
|
|
*/
|
|
public function index()
|
|
{
|
|
$keyword = trim((string) $this->request->get('keyword', ''));
|
|
|
|
$query = DocsProject::order('sort', 'asc')->order('id', 'asc');
|
|
if ($keyword !== '') {
|
|
$query->where(function ($q) use ($keyword) {
|
|
$q->whereLike('title', '%' . $keyword . '%')
|
|
->whereOr('name', 'like', '%' . $keyword . '%');
|
|
});
|
|
}
|
|
|
|
$list = $query->select()->toArray();
|
|
|
|
// 附加每个项目的版本数与文档数,便于列表直观查看
|
|
foreach ($list as &$item) {
|
|
$item['version_count'] = DocsVersion::where('project_id', $item['id'])->count();
|
|
$item['doc_count'] = DocsDoc::where('project_id', $item['id'])->count();
|
|
}
|
|
unset($item);
|
|
|
|
$this->assign('list', $list);
|
|
$this->assign('keyword', $keyword);
|
|
return $this->fetch('project/index');
|
|
}
|
|
|
|
/**
|
|
* 新增页面
|
|
*/
|
|
public function add()
|
|
{
|
|
$this->assign('addonList', $this->addonOptions());
|
|
return $this->fetch('project/add');
|
|
}
|
|
|
|
/**
|
|
* 保存新增
|
|
*/
|
|
public function save()
|
|
{
|
|
$data = $this->collect();
|
|
if (is_string($data)) {
|
|
return $this->result->error($data);
|
|
}
|
|
|
|
if (DocsProject::where('name', $data['name'])->find()) {
|
|
return $this->result->error('项目标识已存在,请更换');
|
|
}
|
|
|
|
$project = DocsProject::create($data);
|
|
|
|
// 新项目自动建默认版本,避免建完项目无法直接添加文档
|
|
DocsVersion::create([
|
|
'project_id' => $project->id,
|
|
'name' => 'v1',
|
|
'title' => '默认版本',
|
|
'is_default' => 1,
|
|
'sort' => 1,
|
|
'status' => 1,
|
|
]);
|
|
|
|
DocTreeService::clearCache();
|
|
return $this->result->success(null, '添加成功');
|
|
}
|
|
|
|
/**
|
|
* 编辑页面
|
|
*
|
|
* @param int $id 项目ID
|
|
*/
|
|
public function edit($id = null)
|
|
{
|
|
$project = DocsProject::find((int) $id);
|
|
if (!$project) {
|
|
return $this->result->error('项目不存在');
|
|
}
|
|
|
|
$this->assign('item', $project);
|
|
$this->assign('addonList', $this->addonOptions());
|
|
$this->assign('versionList', DocsVersion::where('project_id', $project->id)->order('sort', 'asc')->select());
|
|
return $this->fetch('project/edit');
|
|
}
|
|
|
|
/**
|
|
* 保存编辑
|
|
*
|
|
* @param int $id 项目ID
|
|
*/
|
|
public function update($id)
|
|
{
|
|
$project = DocsProject::find((int) $id);
|
|
if (!$project) {
|
|
return $this->result->error('项目不存在');
|
|
}
|
|
|
|
$data = $this->collect();
|
|
if (is_string($data)) {
|
|
return $this->result->error($data);
|
|
}
|
|
|
|
if (DocsProject::where('name', $data['name'])->where('id', '<>', $project->id)->find()) {
|
|
return $this->result->error('项目标识已存在,请更换');
|
|
}
|
|
|
|
$project->save($data);
|
|
DocTreeService::clearCache();
|
|
return $this->result->success(null, '保存成功');
|
|
}
|
|
|
|
/**
|
|
* 删除项目(连同版本与文档)
|
|
*
|
|
* @param int $id 项目ID
|
|
*/
|
|
public function delete($id)
|
|
{
|
|
$id = (int) $id;
|
|
$project = DocsProject::find($id);
|
|
if (!$project) {
|
|
return $this->result->error('项目不存在');
|
|
}
|
|
|
|
// 事务化级联:项目 / 版本 / 文档 / 搜索索引,任何一步失败整体回滚
|
|
try {
|
|
Db::transaction(function () use ($id) {
|
|
$docIds = DocsDoc::where('project_id', $id)->column('id');
|
|
foreach ($docIds as $docId) {
|
|
DocSearchService::remove((int) $docId);
|
|
}
|
|
|
|
DocsDoc::where('project_id', $id)->delete();
|
|
DocsVersion::where('project_id', $id)->delete();
|
|
DocsProject::where('id', $id)->delete();
|
|
});
|
|
} catch (\Throwable $e) {
|
|
return $this->result->error('删除失败:' . $e->getMessage());
|
|
}
|
|
|
|
DocTreeService::clearCache();
|
|
return $this->result->success(null, '删除成功');
|
|
}
|
|
|
|
/**
|
|
* 收集并校验表单数据
|
|
*
|
|
* @return array|string 校验失败返回错误提示字符串
|
|
*/
|
|
protected function collect()
|
|
{
|
|
$name = trim((string) $this->request->post('name', ''));
|
|
$title = trim((string) $this->request->post('title', ''));
|
|
|
|
if ($name === '' || !preg_match('/^[a-z][a-z0-9_\-]{1,49}$/i', $name)) {
|
|
return '项目标识只能为字母开头的字母数字组合,长度 2-50';
|
|
}
|
|
if ($title === '') {
|
|
return '请填写项目名称';
|
|
}
|
|
|
|
return [
|
|
'name' => $name,
|
|
'title' => $title,
|
|
'intro' => trim((string) $this->request->post('intro', '')),
|
|
'logo' => trim((string) $this->request->post('logo', '')),
|
|
'addon' => trim((string) $this->request->post('addon', '')),
|
|
'default_version' => trim((string) $this->request->post('default_version', '')),
|
|
'sort' => (int) $this->request->post('sort', 0),
|
|
'status' => (int) $this->request->post('status', 1) === 1 ? 1 : 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 扫描已安装插件,供项目归属下拉使用
|
|
*
|
|
* @return array
|
|
*/
|
|
protected function addonOptions(): array
|
|
{
|
|
$dir = root_path() . 'addon';
|
|
$list = [];
|
|
|
|
if (!is_dir($dir)) {
|
|
return $list;
|
|
}
|
|
|
|
foreach ((array) scandir($dir) as $item) {
|
|
if ($item === '.' || $item === '..' || !is_dir($dir . DIRECTORY_SEPARATOR . $item)) {
|
|
continue;
|
|
}
|
|
$infoFile = $dir . DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . 'info.php';
|
|
if (!is_file($infoFile)) {
|
|
continue;
|
|
}
|
|
$info = @include $infoFile;
|
|
if (is_array($info) && !empty($info['name'])) {
|
|
$list[] = [
|
|
'name' => (string) $info['name'],
|
|
'title' => (string) ($info['title'] ?? $info['name']),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
}
|