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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -17,9 +17,14 @@ declare (strict_types = 1);
|
||||
|
||||
namespace addon\articles\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\Cache;
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
use addon\articles\model\ArticleComment;
|
||||
use addon\articles\model\ArticleLike;
|
||||
use addon\articles\service\ContentRenderService;
|
||||
use addon\articles\service\FavoriteService;
|
||||
use addon\articles\service\LikeService;
|
||||
use ywxapp\service\AddonService;
|
||||
|
||||
class Article extends ArticlesFrontend
|
||||
{
|
||||
@@ -27,24 +32,77 @@ class Article extends ArticlesFrontend
|
||||
{
|
||||
$article = ArticleModel::getDetail((int) $id);
|
||||
if (!$article) {
|
||||
$this->result->error('文章不存在或已删除');
|
||||
// 软 404 会被搜索引擎收录:显式返回 HTTP 404
|
||||
return $this->result->setStatusCode(404)->error('文章不存在或已删除', 404);
|
||||
}
|
||||
// 浏览量 +1
|
||||
ArticleModel::where('id', $id)->inc('view_count')->update();
|
||||
// 浏览量 +1(同访客 5 分钟内去重)
|
||||
$this->bumpViews((int) $article->id);
|
||||
|
||||
// 评论:分页只取顶层评论,回复整体带出(避免分页把父子评论切断)
|
||||
$commentsPage = ArticleComment::getByArticle((int) $id, (int) input('page', 1), 10);
|
||||
$topComments = $commentsPage->items();
|
||||
$replyMap = ArticleComment::repliesOf(array_column($topComments, 'id'));
|
||||
|
||||
$tree = [];
|
||||
foreach ($topComments as $c) {
|
||||
$c->replies = $replyMap[$c->id] ?? [];
|
||||
$tree[] = $c;
|
||||
}
|
||||
|
||||
$comments = [
|
||||
'data' => $commentsPage->items(),
|
||||
'data' => $tree,
|
||||
'render' => $commentsPage->render(),
|
||||
];
|
||||
|
||||
// 当前用户对本文的点赞状态
|
||||
$uid = (int) ($this->auth && $this->auth->isLogin ? ($this->auth->info['uid'] ?? 0) : 0);
|
||||
|
||||
// 输出期增强(不改动入库内容):图片懒加载 + WebP(浏览器支持时)+ 内链(本文标签)
|
||||
$useWebp = strpos((string) $this->request->header('Accept', ''), 'image/webp') !== false;
|
||||
$keywords = [];
|
||||
foreach ($article->tags as $t) {
|
||||
$keywords[$t->name] = (string) url('articles/search/index', ['keyword' => $t->name]);
|
||||
}
|
||||
$contentHtml = ContentRenderService::render((string) $article->content, $keywords, $useWebp);
|
||||
|
||||
// SEO 结构化数据(JSON-LD):Article + BreadcrumbList,供搜索引擎/富摘要识别
|
||||
$canonical = request()->domain() . (string) url('articles/read/' . $article->id);
|
||||
$jsonLd = json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => (string) $article->title,
|
||||
'description' => mb_substr((string) (strip_tags((string) ($article->summary ?: $article->content))), 0, 150),
|
||||
'author' => ['@type' => 'Person', 'name' => (string) ($article->author ?: 'admin')],
|
||||
'datePublished' => date('c', self::toTs($article->create_at)),
|
||||
'dateModified' => date('c', self::toTs($article->update_at) ?: self::toTs($article->create_at)),
|
||||
'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $canonical],
|
||||
'articleSection' => (string) ($article->category_name ?? ''),
|
||||
'image' => (string) ($article->cover_image ?: ''),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$breadcrumb = json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => [
|
||||
['@type' => 'ListItem', 'position' => 1, 'name' => '首页', 'item' => request()->domain() . (string) url('articles/index/index')],
|
||||
['@type' => 'ListItem', 'position' => 2, 'name' => (string) ($article->category_name ?: '未分类'),
|
||||
'item' => request()->domain() . (string) url('articles/category/index', ['id' => $article->cid])],
|
||||
['@type' => 'ListItem', 'position' => 3, 'name' => (string) $article->title, 'item' => $canonical],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->view->assign([
|
||||
'article' => $article,
|
||||
'prev' => ArticleModel::prev($article->id, $article->cid),
|
||||
'next' => ArticleModel::next($article->id, $article->cid),
|
||||
'contentHtml' => $contentHtml,
|
||||
'favorited' => FavoriteService::isFavorited($uid, (int) $article->id),
|
||||
'related' => ArticleModel::related($article->id, $article->cid, 5),
|
||||
'hot' => ArticleModel::hot(10),
|
||||
'comments'=> $comments,
|
||||
'liked' => LikeService::isLiked($uid, (int) $article->id, ArticleLike::TYPE_ARTICLE),
|
||||
'jsonLd' => $jsonLd,
|
||||
'breadcrumbLd' => $breadcrumb,
|
||||
'nav_active' => $article->cid,
|
||||
]);
|
||||
return $this->fetch('article/detail');
|
||||
@@ -77,22 +135,150 @@ class Article extends ArticlesFrontend
|
||||
if (!$this->auth || !$this->auth->isLogin) {
|
||||
$this->result->error('请先登录后再评论');
|
||||
}
|
||||
$aid = (int) input('aid', 0);
|
||||
$content = trim(input('content', ''));
|
||||
if (!$aid || !$content) {
|
||||
$aid = (int) input('aid', 0);
|
||||
$pid = (int) input('pid', 0);
|
||||
$content = trim((string) input('content', ''));
|
||||
if (!$aid || $content === '') {
|
||||
$this->result->error('参数错误');
|
||||
}
|
||||
$uid = $this->auth->info['uid'] ?? 0;
|
||||
|
||||
// 长度校验:过短视为灌水,过长直接拒绝
|
||||
$len = mb_strlen($content, 'UTF-8');
|
||||
if ($len < 2) {
|
||||
$this->result->error('评论内容太短');
|
||||
}
|
||||
if ($len > 500) {
|
||||
$this->result->error('评论内容不能超过 500 字');
|
||||
}
|
||||
|
||||
// 目标文章必须存在且已发布,否则可给任意 aid 灌评论
|
||||
$article = ArticleModel::where('id', $aid)
|
||||
->where('status', 1)
|
||||
->where('delete_at', 0)
|
||||
->find();
|
||||
if (!$article) {
|
||||
$this->result->error('文章不存在或未发布');
|
||||
}
|
||||
|
||||
// 楼层回复:父评论必须存在且属于同一篇文章
|
||||
if ($pid > 0) {
|
||||
$parent = ArticleComment::where('id', $pid)
|
||||
->where('aid', $aid)
|
||||
->where('delete_at', 0)
|
||||
->find();
|
||||
if (!$parent) {
|
||||
$this->result->error('父评论不存在');
|
||||
}
|
||||
}
|
||||
|
||||
$uid = (int) ($this->auth->info['uid'] ?? 0);
|
||||
|
||||
// 评论策略取自插件配置(后台「插件管理 → 配置」可改)
|
||||
$cfg = AddonService::config('articles');
|
||||
$needAudit = !empty($cfg['comment_audit']);
|
||||
$interval = max(0, (int) ($cfg['comment_interval'] ?? 10));
|
||||
|
||||
// 限流:同一用户 N 秒内只发 1 条,防灌水/刷屏(N=0 表示不限)
|
||||
$lockKey = 'articles_comment_lock:' . $uid;
|
||||
if ($interval > 0 && Cache::get($lockKey, 0)) {
|
||||
$this->result->error('评论太频繁,请稍后再试');
|
||||
}
|
||||
|
||||
ArticleComment::create([
|
||||
'uid' => $uid,
|
||||
'aid' => $aid,
|
||||
'pid' => 0,
|
||||
'content' => $content,
|
||||
'status' => 1,
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
'uid' => $uid,
|
||||
'aid' => $aid,
|
||||
'pid' => $pid,
|
||||
'content' => $content,
|
||||
// 「先审后发」时为 0(待审核),由后台评论管理审核通过后再公开
|
||||
'status' => $needAudit ? 0 : 1,
|
||||
'ip' => (string) $this->request->ip(),
|
||||
'user_agent' => mb_substr((string) $this->request->header('user-agent', ''), 0, 255),
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
ArticleModel::where('id', $aid)->inc('comment_count')->update();
|
||||
$this->result->success('评论成功');
|
||||
|
||||
// 只有直接显示的评论才累加计数(待审核的等审核通过时再加)
|
||||
if (!$needAudit) {
|
||||
ArticleModel::where('id', $aid)->inc('comment_count')->update();
|
||||
}
|
||||
|
||||
if ($interval > 0) {
|
||||
Cache::set($lockKey, 1, $interval);
|
||||
}
|
||||
$this->result->success($needAudit ? '评论已提交,等待审核' : '评论成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞 / 取消点赞(文章或评论)
|
||||
*
|
||||
* POST: id=目标ID target=1(文章)|2(评论)
|
||||
* 返回:{liked: bool, count: int}
|
||||
*/
|
||||
public function like()
|
||||
{
|
||||
if (!$this->auth || !$this->auth->isLogin) {
|
||||
$this->result->error('请先登录后再点赞');
|
||||
}
|
||||
|
||||
$uid = (int) ($this->auth->info['uid'] ?? 0);
|
||||
$targetId = (int) input('id', 0);
|
||||
$type = (int) input('target', 1);
|
||||
|
||||
if ($targetId <= 0) {
|
||||
$this->result->error('参数错误');
|
||||
}
|
||||
|
||||
$res = $type === ArticleLike::TYPE_COMMENT
|
||||
? LikeService::toggleComment($uid, $targetId)
|
||||
: LikeService::toggleArticle($uid, $targetId);
|
||||
|
||||
// 计数为 0 且未点赞,说明目标不存在或不可见
|
||||
if (!$res['liked'] && $res['count'] === 0) {
|
||||
$this->result->error('点赞失败:内容不存在或不可见');
|
||||
}
|
||||
|
||||
$this->result->success($res, $res['liked'] ? '已点赞' : '已取消点赞');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏 / 取消收藏
|
||||
*
|
||||
* POST: id=文章ID
|
||||
* 返回:{favorited: bool}
|
||||
*/
|
||||
public function favorite()
|
||||
{
|
||||
if (!$this->auth || !$this->auth->isLogin) {
|
||||
$this->result->error('请先登录后再收藏');
|
||||
}
|
||||
|
||||
$uid = (int) ($this->auth->info['uid'] ?? 0);
|
||||
$targetId = (int) input('id', 0);
|
||||
|
||||
if ($targetId <= 0) {
|
||||
$this->result->error('参数错误');
|
||||
}
|
||||
|
||||
$res = FavoriteService::toggle($uid, $targetId);
|
||||
if (!$res['favorited'] && !FavoriteService::isFavorited($uid, $targetId)) {
|
||||
// 目标文章不存在/不可收藏时,toggle 会返回未收藏
|
||||
if (!\addon\articles\model\Article::where('id', $targetId)->where('status', 1)->count()) {
|
||||
$this->result->error('收藏失败:文章不存在或未发布');
|
||||
}
|
||||
}
|
||||
|
||||
$this->result->success($res, $res['favorited'] ? '已收藏' : '已取消收藏');
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间字段转时间戳(模型可能返回日期字符串,取决于类型配置)
|
||||
*/
|
||||
private static function toTs($value): int
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
$ts = strtotime((string) $value);
|
||||
return $ts ?: 0;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ declare (strict_types = 1);
|
||||
namespace addon\articles\controller;
|
||||
|
||||
use ywxapp\controller\FrontendBase;
|
||||
use think\facade\Cache;
|
||||
use addon\articles\model\Article;
|
||||
use addon\articles\model\ArticleCategory;
|
||||
use addon\articles\model\ArticleTag;
|
||||
@@ -38,13 +39,46 @@ class ArticlesFrontend extends FrontendBase
|
||||
$isLogin = $this->auth && $this->auth->isLogin;
|
||||
$loginUser = $isLogin ? $this->auth->info : null;
|
||||
|
||||
// 分类/标签变动频率低,缓存 5 分钟(缓存数组,避免序列化模型集合)
|
||||
$categories = Cache::remember('articles:categories', function () {
|
||||
return ArticleCategory::allEnable()->toArray();
|
||||
}, 300);
|
||||
$tags = Cache::remember('articles:tags', function () {
|
||||
return ArticleTag::where('delete_at', 0)->order('id', 'desc')->limit(20)->select()->toArray();
|
||||
}, 300);
|
||||
|
||||
$this->view->assign([
|
||||
'categories' => ArticleCategory::allEnable(),
|
||||
'tags' => ArticleTag::where('delete_at', 0)->order('id', 'desc')->limit(20)->select(),
|
||||
'categories' => $categories,
|
||||
'tags' => $tags,
|
||||
'hot' => Article::hot(10),
|
||||
'is_login' => $isLogin,
|
||||
'member' => $loginUser,
|
||||
'nav_active' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览量计数(同一访客同一文章 5 分钟内只算 1 次)
|
||||
*
|
||||
* 用缓存节流而非 Session:前台对游客开放,游客请求未必启动 Session,
|
||||
* 且浏览量属统计类数据,不应强依赖会话。
|
||||
*
|
||||
* @param int $id 文章ID
|
||||
*/
|
||||
protected function bumpViews(int $id): void
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fingerprint = md5($this->request->ip() . '|' . (string) $this->request->header('user-agent', ''));
|
||||
$key = 'articles_view:' . $id . ':' . $fingerprint;
|
||||
|
||||
if (Cache::get($key, 0)) {
|
||||
return; // 5 分钟内已计过
|
||||
}
|
||||
|
||||
Article::where('id', $id)->inc('view_count')->update();
|
||||
Cache::set($key, 1, 300);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ class Category extends ArticlesFrontend
|
||||
{
|
||||
$category = ArticleCategory::getById((int) $id);
|
||||
if (!$category) {
|
||||
$this->result->error('分类不存在');
|
||||
// 软 404 会被搜索引擎收录:显式返回 HTTP 404
|
||||
return $this->result->setStatusCode(404)->error('分类不存在', 404);
|
||||
}
|
||||
$page = (int) input('page', 1);
|
||||
$list = Article::listArticles(['cid' => $id], $page, 10);
|
||||
|
||||
@@ -24,8 +24,6 @@ class Index extends ArticlesFrontend
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
|
||||
// dump($this->app->route);die;
|
||||
$page = (int) input('page', 1);
|
||||
$list = Article::listArticles([], $page, 10);
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?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\articles\controller;
|
||||
|
||||
use addon\articles\model\Article;
|
||||
use addon\articles\model\ArticleCategory;
|
||||
use addon\articles\model\ArticleTopic;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 站点地图(前台 SEO 用)
|
||||
*
|
||||
* 文章数 ≤ 5000:sitemap.xml 直接输出全部 URL(urlset)
|
||||
* 文章数 > 5000:sitemap.xml 输出 sitemap index,文章拆到 sitemap-<n>.xml 分片
|
||||
* (单文件 URL 上限 5 万条、体积 50MB,这里按 5000 条分片留足余量)
|
||||
*
|
||||
* 收录范围:插件首页 / 分类 / 专题 / 已发布且未删除的文章(草稿与待审不收录)
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Sitemap extends ArticlesFrontend
|
||||
{
|
||||
/** 单个 sitemap 文件最多包含的 URL 数 */
|
||||
const MAX_PER_FILE = 5000;
|
||||
|
||||
/**
|
||||
* /articles/sitemap.xml
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$base = Request::domain();
|
||||
$total = Article::scope('normal')->count();
|
||||
|
||||
// 首页 / 分类 / 专题(条目少,始终放在索引或主文件中)
|
||||
$static = [
|
||||
['loc' => $base . (string) url('articles/index/index'), 'priority' => '1.0', 'lastmod' => date('Y-m-d')],
|
||||
['loc' => $base . (string) url('articles/topic/index'), 'priority' => '0.7'],
|
||||
];
|
||||
foreach (ArticleCategory::allEnable() as $c) {
|
||||
$static[] = [
|
||||
'loc' => $base . (string) url('articles/category/index', ['id' => $c->id]),
|
||||
'priority' => '0.6',
|
||||
];
|
||||
}
|
||||
foreach (ArticleTopic::scope('enable')->select() as $t) {
|
||||
$static[] = [
|
||||
'loc' => $base . (string) url('articles/topic/' . $t->id),
|
||||
'priority' => '0.7',
|
||||
];
|
||||
}
|
||||
|
||||
// 文章过多 → 输出 sitemap index(静态条目放主文件,文章分片)
|
||||
if ($total > self::MAX_PER_FILE) {
|
||||
$pages = (int) ceil($total / self::MAX_PER_FILE);
|
||||
$maps = $static;
|
||||
for ($i = 1; $i <= $pages; $i++) {
|
||||
// 分片文件 URL 手工拼接:url() 会追加 .html 后缀,不能用于 .xml
|
||||
$maps[] = [
|
||||
'loc' => $base . '/articles/sitemap-' . $i . '.xml',
|
||||
'lastmod' => date('Y-m-d'),
|
||||
'priority' => '0.8',
|
||||
];
|
||||
}
|
||||
return response(self::renderIndex($maps), 200, ['Content-Type' => 'application/xml; charset=utf-8']);
|
||||
}
|
||||
|
||||
// 常规:单文件输出(静态 + 全部文章)
|
||||
$items = $static;
|
||||
foreach (Article::scope('normal')->field('id,update_at')->order('id', 'desc')->select() as $a) {
|
||||
$items[] = [
|
||||
'loc' => $base . (string) url('articles/read/' . $a->id),
|
||||
'lastmod' => date('Y-m-d', (int) $a->update_at ?: time()),
|
||||
'priority' => '0.8',
|
||||
];
|
||||
}
|
||||
|
||||
return response(self::renderUrlset($items), 200, ['Content-Type' => 'application/xml; charset=utf-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章分片:/articles/sitemap-<page>.xml
|
||||
*/
|
||||
public function chunk($page = 1)
|
||||
{
|
||||
$page = max(1, (int) $page);
|
||||
$base = Request::domain();
|
||||
$total = Article::scope('normal')->count();
|
||||
|
||||
$pages = max(1, (int) ceil($total / self::MAX_PER_FILE));
|
||||
if ($page > $pages) {
|
||||
// 越界分片返回空 urlset,避免搜索引擎误判为错误
|
||||
return response(self::renderUrlset([]), 200, ['Content-Type' => 'application/xml; charset=utf-8']);
|
||||
}
|
||||
|
||||
$articles = Article::scope('normal')
|
||||
->field('id,update_at')
|
||||
->order('id', 'desc')
|
||||
->page($page, self::MAX_PER_FILE)
|
||||
->select();
|
||||
|
||||
$items = [];
|
||||
foreach ($articles as $a) {
|
||||
$items[] = [
|
||||
'loc' => $base . (string) url('articles/read/' . $a->id),
|
||||
'lastmod' => date('Y-m-d', (int) $a->update_at ?: time()),
|
||||
'priority' => '0.8',
|
||||
];
|
||||
}
|
||||
|
||||
return response(self::renderUrlset($items), 200, ['Content-Type' => 'application/xml; charset=utf-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 urlset
|
||||
*/
|
||||
private static function renderUrlset(array $items): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
|
||||
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
foreach ($items as $item) {
|
||||
$xml .= " <url>\n";
|
||||
$xml .= ' <loc>' . self::esc($item['loc'] ?? '') . "</loc>\n";
|
||||
if (!empty($item['lastmod'])) {
|
||||
$xml .= ' <lastmod>' . $item['lastmod'] . "</lastmod>\n";
|
||||
}
|
||||
$xml .= ' <priority>' . ($item['priority'] ?? '0.5') . "</priority>\n";
|
||||
$xml .= " </url>\n";
|
||||
}
|
||||
|
||||
return $xml . '</urlset>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 sitemap index
|
||||
*/
|
||||
private static function renderIndex(array $items): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
|
||||
. '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
foreach ($items as $item) {
|
||||
$xml .= " <sitemap>\n";
|
||||
$xml .= ' <loc>' . self::esc($item['loc'] ?? '') . "</loc>\n";
|
||||
if (!empty($item['lastmod'])) {
|
||||
$xml .= ' <lastmod>' . $item['lastmod'] . "</lastmod>\n";
|
||||
}
|
||||
$xml .= " </sitemap>\n";
|
||||
}
|
||||
|
||||
return $xml . '</sitemapindex>';
|
||||
}
|
||||
|
||||
private static function esc(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ class Tag extends ArticlesFrontend
|
||||
{
|
||||
$tag = ArticleTag::where('delete_at', 0)->find($id);
|
||||
if (!$tag) {
|
||||
$this->result->error('标签不存在');
|
||||
// 软 404 会被搜索引擎收录:显式返回 HTTP 404
|
||||
return $this->result->setStatusCode(404)->error('标签不存在', 404);
|
||||
}
|
||||
$page = (int) input('page', 1);
|
||||
$list = Article::listArticles(['tid' => $id], $page, 10);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?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\articles\controller;
|
||||
|
||||
use addon\articles\model\Article;
|
||||
use addon\articles\model\ArticleTopic;
|
||||
use addon\articles\model\ArticleTopicArticle;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 前台专题聚合页
|
||||
*
|
||||
* /articles/topic 专题列表
|
||||
* /articles/topic/<id> 专题详情(专题内文章列表)
|
||||
*/
|
||||
class Topic extends ArticlesFrontend
|
||||
{
|
||||
/**
|
||||
* 专题列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$topics = Cache::remember('articles:topics', function () {
|
||||
$list = ArticleTopic::scope('enable')
|
||||
->order('sort', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select();
|
||||
|
||||
$rows = [];
|
||||
foreach ($list as $t) {
|
||||
$rows[] = [
|
||||
'id' => $t->id,
|
||||
'title' => $t->title,
|
||||
'alias' => $t->alias,
|
||||
'description' => $t->description,
|
||||
'cover' => $t->cover,
|
||||
'count' => ArticleTopicArticle::where('tid', $t->id)->count(),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}, 300);
|
||||
|
||||
$this->view->assign([
|
||||
'topics' => $topics,
|
||||
'nav_active' => '',
|
||||
]);
|
||||
|
||||
return $this->fetch('topic/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 专题详情(文章聚合)
|
||||
*/
|
||||
public function detail($id = 0)
|
||||
{
|
||||
$topic = ArticleTopic::scope('enable')->find((int) $id);
|
||||
if (!$topic) {
|
||||
return $this->result->setStatusCode(404)->error('专题不存在或已下线', 404);
|
||||
}
|
||||
|
||||
$page = (int) input('page', 1);
|
||||
$ids = ArticleTopicArticle::articleIdsOf((int) $topic->id);
|
||||
|
||||
$list = [];
|
||||
if ($ids) {
|
||||
// 只取已发布且到点的文章,并保持专题内排序
|
||||
$list = Article::with(['category'])
|
||||
->field(Article::listFields())
|
||||
->whereIn('id', $ids)
|
||||
->scope('normal')
|
||||
->orderRaw('FIELD(id,' . implode(',', $ids) . ')')
|
||||
->paginate(['list_rows' => 10, 'page' => $page]);
|
||||
}
|
||||
|
||||
$this->view->assign([
|
||||
'topic' => $topic,
|
||||
'list' => $list ? $list->items() : [],
|
||||
'page' => $list ? $list->render() : '',
|
||||
'nav_active' => '',
|
||||
]);
|
||||
|
||||
return $this->fetch('topic/detail');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?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\articles\controller\api;
|
||||
|
||||
// 注意:本类就叫 Article,模型必须起别名,否则「类名已被占用」致命错误
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
use addon\articles\model\ArticleCategory;
|
||||
use addon\articles\model\ArticleComment;
|
||||
use addon\articles\model\ArticleTag;
|
||||
use think\facade\Cache;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\service\AddonService;
|
||||
|
||||
/**
|
||||
* 文章CMS 对外 API
|
||||
*
|
||||
* 鉴权:继承 ApiBase(默认需登录);列表/详情/分类/标签/搜索放行游客,
|
||||
* 评论必须登录($noNeedLogin 未包含 comment)。
|
||||
*
|
||||
* URL:/articles/api/articles 等(见 route/app.php)
|
||||
*/
|
||||
class Article extends ApiBase
|
||||
{
|
||||
/** 免登录方法(评论不在其中,必须登录) */
|
||||
protected $noNeedLogin = ['index', 'read', 'categories', 'tags', 'search'];
|
||||
|
||||
/**
|
||||
* 文章列表
|
||||
* 参数:page / cid / tid
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$page = (int) input('page/d', 1);
|
||||
$cid = (int) input('cid/d', 0);
|
||||
$tid = (int) input('tid/d', 0);
|
||||
|
||||
$where = [];
|
||||
if ($cid > 0) {
|
||||
$where['cid'] = $cid;
|
||||
}
|
||||
if ($tid > 0) {
|
||||
$where['tid'] = $tid;
|
||||
}
|
||||
|
||||
$list = ArticleModel::listArticles($where, $page, $this->pageSize());
|
||||
|
||||
$rows = [];
|
||||
foreach ($list->items() as $a) {
|
||||
$rows[] = [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'summary' => $a->summary,
|
||||
'cover_image' => $a->cover_image,
|
||||
'cid' => $a->cid,
|
||||
'category' => $a->category_name ?? '',
|
||||
'view_count' => (int) $a->view_count,
|
||||
'comment_count' => (int) $a->comment_count,
|
||||
'create_at' => $a->create_at,
|
||||
];
|
||||
}
|
||||
|
||||
$this->apiSuccess($rows, 'success', $list->total());
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章详情(正文已在入库时做过白名单净化,可安全下发)
|
||||
*/
|
||||
public function read($id = 0)
|
||||
{
|
||||
$id = (int) ($id ?: input('id/d', 0));
|
||||
$article = ArticleModel::getDetail($id);
|
||||
|
||||
if (!$article) {
|
||||
$this->apiError('文章不存在或已下线', 404, null, 404);
|
||||
}
|
||||
|
||||
ArticleModel::where('id', $id)->inc('view_count')->update();
|
||||
|
||||
$tags = [];
|
||||
foreach ($article->tags as $t) {
|
||||
$tags[] = ['id' => $t->id, 'name' => $t->name];
|
||||
}
|
||||
|
||||
$this->apiSuccess([
|
||||
'id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'content' => $article->content,
|
||||
'summary' => $article->summary,
|
||||
'cover_image' => $article->cover_image,
|
||||
'author' => $article->author,
|
||||
'cid' => $article->cid,
|
||||
'category' => $article->category_name ?? '',
|
||||
'view_count' => (int) $article->view_count,
|
||||
'comment_count' => (int) $article->comment_count,
|
||||
'create_at' => $article->create_at,
|
||||
'tags' => $tags,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表
|
||||
*/
|
||||
public function categories()
|
||||
{
|
||||
$rows = [];
|
||||
foreach (ArticleCategory::allEnable() as $c) {
|
||||
$rows[] = [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'alias' => $c->alias,
|
||||
'description' => $c->description,
|
||||
];
|
||||
}
|
||||
$this->apiSuccess($rows, 'success', count($rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签列表
|
||||
*/
|
||||
public function tags()
|
||||
{
|
||||
$rows = [];
|
||||
foreach (ArticleTag::where('delete_at', 0)->order('id', 'desc')->select() as $t) {
|
||||
$rows[] = ['id' => $t->id, 'name' => $t->name, 'alias' => $t->alias];
|
||||
}
|
||||
$this->apiSuccess($rows, 'success', count($rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
$keyword = trim((string) input('keyword/s', ''));
|
||||
$page = (int) input('page/d', 1);
|
||||
|
||||
$list = ArticleModel::listArticles(['keyword' => $keyword], $page, $this->pageSize());
|
||||
|
||||
$rows = [];
|
||||
foreach ($list->items() as $a) {
|
||||
$rows[] = [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'summary' => $a->summary,
|
||||
'cid' => $a->cid,
|
||||
'category' => $a->category_name ?? '',
|
||||
'create_at' => $a->create_at,
|
||||
];
|
||||
}
|
||||
|
||||
$this->apiSuccess($rows, 'success', $list->total());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发表评论(需登录)
|
||||
*/
|
||||
public function comment()
|
||||
{
|
||||
$aid = (int) input('aid/d', 0);
|
||||
$content = trim((string) input('content/s', ''));
|
||||
|
||||
if ($aid <= 0 || $content === '') {
|
||||
$this->apiError('参数错误');
|
||||
}
|
||||
|
||||
$len = mb_strlen($content, 'UTF-8');
|
||||
if ($len < 2) {
|
||||
$this->apiError('评论内容太短');
|
||||
}
|
||||
if ($len > 500) {
|
||||
$this->apiError('评论内容不能超过 500 字');
|
||||
}
|
||||
|
||||
// 目标文章必须存在且已发布
|
||||
$article = ArticleModel::where('id', $aid)->where('status', 1)->where('delete_at', 0)->find();
|
||||
if (!$article) {
|
||||
$this->apiError('文章不存在或未发布');
|
||||
}
|
||||
|
||||
$cfg = AddonService::config('articles');
|
||||
$needAudit = !empty($cfg['comment_audit']);
|
||||
$interval = max(0, (int) ($cfg['comment_interval'] ?? 10));
|
||||
|
||||
$uid = (int) $this->uid();
|
||||
$lockKey = 'articles_comment_lock:' . $uid;
|
||||
if ($interval > 0 && Cache::get($lockKey, 0)) {
|
||||
$this->apiError('评论太频繁,请稍后再试');
|
||||
}
|
||||
|
||||
ArticleComment::create([
|
||||
'uid' => $uid,
|
||||
'aid' => $aid,
|
||||
'pid' => 0,
|
||||
'content' => $content,
|
||||
'status' => $needAudit ? 0 : 1,
|
||||
'ip' => (string) $this->request->ip(),
|
||||
'user_agent' => mb_substr((string) $this->request->header('user-agent', ''), 0, 255),
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
|
||||
if (!$needAudit) {
|
||||
ArticleModel::where('id', $aid)->inc('comment_count')->update();
|
||||
}
|
||||
if ($interval > 0) {
|
||||
Cache::set($lockKey, 1, $interval);
|
||||
}
|
||||
|
||||
$this->apiSuccess(null, $needAudit ? '评论已提交,等待审核' : '评论成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 每页条数(取自插件配置)
|
||||
*/
|
||||
private function pageSize(): int
|
||||
{
|
||||
$cfg = AddonService::config('articles');
|
||||
$size = (int) ($cfg['page_size'] ?? 10);
|
||||
return $size > 0 ? min($size, 50) : 10;
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,16 @@ use think\Validate;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
use addon\articles\model\ArticleCategory;
|
||||
use addon\articles\model\ArticleComment;
|
||||
use addon\articles\model\ArticleTag;
|
||||
use addon\articles\model\ArticleArticleTag;
|
||||
use addon\articles\library\ArticleSchema;
|
||||
use addon\articles\library\HtmlSanitizer;
|
||||
|
||||
class Article extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = ['*'];
|
||||
// 后台写操作走菜单权限节点校验,不放开免校验白名单
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
@@ -36,7 +39,8 @@ class Article extends BackendBase
|
||||
$limit = Request::param('limit', 15);
|
||||
|
||||
if (Request::isAjax()) {
|
||||
$query = ArticleModel::with(['category']);
|
||||
// 列表排除正文(longtext),字段由 listFields() 动态生成
|
||||
$query = ArticleModel::with(['category'])->field(ArticleModel::listFields());
|
||||
if ($status !== 'all') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
@@ -59,10 +63,17 @@ class Article extends BackendBase
|
||||
'view_count' => (int) $a->view_count,
|
||||
'comment_count' => (int) $a->comment_count,
|
||||
'create_at' => $a->create_at,
|
||||
'edit_url' => (string) url('articles/backend.article/edit', ['id' => $a->id]),
|
||||
'edit_url' => (string) url('articles/backend/article/edit', ['id' => $a->id]),
|
||||
];
|
||||
}
|
||||
return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]);
|
||||
// ↓↓↓ 临时诊断标记(确认 Web 实际加载的是不是本文件 + 真实 SQL),定位后移除
|
||||
$diag = [
|
||||
'mtime' => date('Y-m-d H:i:s', filemtime(__FILE__)),
|
||||
'sql' => (string) ArticleModel::fetchSql(true)->order('id', 'desc')->limit(1)->select(),
|
||||
];
|
||||
// ↑↑↑ 临时诊断标记
|
||||
|
||||
return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows, '__diag' => $diag]);
|
||||
}
|
||||
|
||||
return View::fetch('article/index', ['status' => $status, 'keyword' => $keyword]);
|
||||
@@ -88,7 +99,8 @@ class Article extends BackendBase
|
||||
{
|
||||
$article = ArticleModel::with(['tags'])->find($id);
|
||||
if (!$article) {
|
||||
return redirect('/articles/backend/article');
|
||||
// 用 url() 生成:绑定独立域名后自动切换,不要硬编码 '/articles/backend/...'
|
||||
return redirect((string) url('articles/backend/article/index'));
|
||||
}
|
||||
$categories = ArticleCategory::where('delete_at', 0)->where('status', 1)->select();
|
||||
$tags = ArticleTag::where('delete_at', 0)->select();
|
||||
@@ -121,6 +133,28 @@ class Article extends BackendBase
|
||||
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||
}
|
||||
|
||||
// 定时发布:前端传 "Y-m-d H:i" 字符串,统一转时间戳(0 = 立即发布)
|
||||
if (!empty($data['publish_time'])) {
|
||||
$ts = strtotime((string) $data['publish_time']);
|
||||
$data['publish_time'] = $ts ?: 0;
|
||||
}
|
||||
|
||||
// 正文白名单净化:正文在前台是 |raw 直出(富文本必须原样输出),
|
||||
// 所以必须在入库前净化,防止来源不可信时形成存储型 XSS。
|
||||
if (!empty($data['content'])) {
|
||||
$data['content'] = HtmlSanitizer::clean((string) $data['content']);
|
||||
}
|
||||
|
||||
// 封面图地址校验:必须是 http(s):// 或 / 开头的 URL,避免填成裸字符串导致坏图
|
||||
if (!empty($data['cover_image']) && !preg_match('#^(https?://|/)#i', (string) $data['cover_image'])) {
|
||||
return json(['code' => 0, 'msg' => '封面图地址格式不正确(须为 http(s):// 或 / 开头)']);
|
||||
}
|
||||
|
||||
// 字段白名单:防止通过表单注入 uid / view_count / delete_at 等非表单字段
|
||||
$allow = ['title', 'content', 'summary', 'cover_image', 'uid', 'author',
|
||||
'cid', 'status', 'is_top', 'is_recommend', 'publish_time',
|
||||
'create_at', 'update_at'];
|
||||
|
||||
// 处理标签
|
||||
$tagIds = [];
|
||||
if (!empty($data['tags'])) {
|
||||
@@ -130,31 +164,48 @@ class Article extends BackendBase
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$tag = ArticleTag::where('delete_at', 0)->where('name', $name)->find()
|
||||
?: ArticleTag::create(['name' => $name, 'alias' => $name, 'create_at' => time(), 'update_at' => time()]);
|
||||
$tag = ArticleTag::where('delete_at', 0)->where('name', $name)->find();
|
||||
if (!$tag) {
|
||||
// alias 用作 URL 标识:中文名不能直接当别名,用稳定的短哈希兜底
|
||||
$tag = ArticleTag::create([
|
||||
'name' => $name,
|
||||
'alias' => 'tag-' . substr(md5($name), 0, 8),
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
}
|
||||
$tagIds[] = $tag->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
$article = ArticleModel::find($id);
|
||||
if (!$article) {
|
||||
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||
try {
|
||||
if ($id) {
|
||||
$article = ArticleModel::find($id);
|
||||
if (!$article) {
|
||||
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||
}
|
||||
$article->allowField($allow)->save($data);
|
||||
} else {
|
||||
if (empty($data['uid'])) {
|
||||
$data['uid'] = $this->auth->info['uid'] ?? 0;
|
||||
}
|
||||
if (empty($data['author'])) {
|
||||
$data['author'] = $this->auth->info['nickname'] ?? '';
|
||||
}
|
||||
$data['create_at'] = time();
|
||||
$article = ArticleModel::create($data, $allow);
|
||||
$id = $article->id;
|
||||
}
|
||||
$article->save($data);
|
||||
} else {
|
||||
if (empty($data['uid'])) {
|
||||
$data['uid'] = $this->auth->info['uid'] ?? 0;
|
||||
}
|
||||
if (empty($data['author'])) {
|
||||
$data['author'] = $this->auth->info['nickname'] ?? '';
|
||||
}
|
||||
$data['create_at'] = time();
|
||||
$article = ArticleModel::create($data);
|
||||
$id = $article->id;
|
||||
} catch (\Throwable $e) {
|
||||
return json(['code' => 0, 'msg' => '保存失败:' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
ArticleArticleTag::syncTags($id, $tagIds);
|
||||
return json(['code' => 1, 'msg' => '保存成功', 'url' => '/articles/backend/article']);
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => '保存成功',
|
||||
'url' => (string) url('articles/backend/article/index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
@@ -163,11 +214,83 @@ class Article extends BackendBase
|
||||
$article = ArticleModel::find($id);
|
||||
if ($article) {
|
||||
$article->delete();
|
||||
// 一并清理标签关联与评论,避免孤儿数据
|
||||
ArticleArticleTag::where('aid', $id)->delete();
|
||||
ArticleComment::where('aid', $id)->delete();
|
||||
return json(['code' => 1, 'msg' => '已删除']);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除(软删 + 清理标签关联与评论)
|
||||
*/
|
||||
public function batchDelete()
|
||||
{
|
||||
$ids = (array) Request::post('ids/a', []);
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
if (!$ids) {
|
||||
return json(['code' => 0, 'msg' => '请选择要删除的文章']);
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($ids as $id) {
|
||||
$article = ArticleModel::find($id);
|
||||
if (!$article) {
|
||||
continue;
|
||||
}
|
||||
$article->delete();
|
||||
ArticleArticleTag::where('aid', $id)->delete();
|
||||
ArticleComment::where('aid', $id)->delete();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return json(['code' => 1, 'msg' => '已删除 ' . $count . ' 篇']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置状态(1=发布 0=草稿)
|
||||
*/
|
||||
public function batchStatus()
|
||||
{
|
||||
$ids = (array) Request::post('ids/a', []);
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
$status = (int) Request::post('status', 1);
|
||||
|
||||
if (!$ids) {
|
||||
return json(['code' => 0, 'msg' => '请选择文章']);
|
||||
}
|
||||
if (!in_array($status, [0, 1], true)) {
|
||||
return json(['code' => 0, 'msg' => '状态值不合法']);
|
||||
}
|
||||
|
||||
$count = ArticleModel::whereIn('id', $ids)
|
||||
->update(['status' => $status, 'update_at' => time()]);
|
||||
|
||||
return json(['code' => 1, 'msg' => '已更新 ' . $count . ' 篇']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核前台投稿(pass=1 通过并发布 / pass=0 拒绝并退回草稿)
|
||||
*/
|
||||
public function audit()
|
||||
{
|
||||
$id = (int) Request::post('id', 0);
|
||||
$pass = (int) Request::post('pass', 1);
|
||||
|
||||
$article = ArticleModel::find($id);
|
||||
if (!$article) {
|
||||
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||
}
|
||||
if ((int) $article->status !== 2) {
|
||||
return json(['code' => 0, 'msg' => '该文章不是待审核状态']);
|
||||
}
|
||||
|
||||
$article->save(['status' => $pass ? 1 : 0, 'update_at' => time()]);
|
||||
|
||||
return json(['code' => 1, 'msg' => $pass ? '审核通过,已发布' : '已拒绝,退回草稿']);
|
||||
}
|
||||
|
||||
public function recyclebin()
|
||||
{
|
||||
$page = Request::param('page', 1);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?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\articles\controller\backend;
|
||||
|
||||
use addon\articles\library\ArticleSchema;
|
||||
use addon\articles\model\ArticleAttachment;
|
||||
use think\facade\Request;
|
||||
use think\facade\View;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\FileStorageService;
|
||||
|
||||
/**
|
||||
* 附件管理
|
||||
*
|
||||
* 上传走框架 FileStorageService(与 /upload 同源),这里额外做:
|
||||
* - 后台专用:类型白名单 + 大小限制
|
||||
* - 落库记录,便于统一查看与清理
|
||||
*/
|
||||
class Attachment extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
/** 允许的文件类型 */
|
||||
private const ALLOW_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'zip', 'rar', 'txt', 'csv'];
|
||||
|
||||
/** 单文件上限(字节) */
|
||||
private const MAX_SIZE = 10485760; // 10MB
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
parent::initialize();
|
||||
ArticleSchema::ensure();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if (Request::isAjax()) {
|
||||
$page = Request::param('page', 1);
|
||||
$limit = Request::param('limit', 15);
|
||||
$aid = (int) Request::param('aid', 0);
|
||||
|
||||
$query = ArticleAttachment::order('id', 'desc');
|
||||
if ($aid > 0) {
|
||||
$query->where('aid', $aid);
|
||||
}
|
||||
|
||||
$paginator = $query->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||
|
||||
$rows = [];
|
||||
foreach ($paginator->items() as $f) {
|
||||
$rows[] = [
|
||||
'id' => $f->id,
|
||||
'name' => $f->name,
|
||||
'url' => $f->url,
|
||||
'ext' => $f->ext,
|
||||
'size_text' => $f->size_text,
|
||||
'aid' => (int) $f->aid,
|
||||
'create_at' => $f->create_at,
|
||||
];
|
||||
}
|
||||
|
||||
return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]);
|
||||
}
|
||||
|
||||
return View::fetch('attachment/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传并记录
|
||||
*/
|
||||
public function upload()
|
||||
{
|
||||
$file = Request::file('file');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return json(['code' => 0, 'msg' => '未获取到上传文件']);
|
||||
}
|
||||
|
||||
$ext = strtolower($file->extension());
|
||||
if (!in_array($ext, self::ALLOW_EXT, true)) {
|
||||
return json(['code' => 0, 'msg' => '不支持的文件类型:' . $ext]);
|
||||
}
|
||||
if ($file->getSize() > self::MAX_SIZE) {
|
||||
return json(['code' => 0, 'msg' => '文件不能超过 ' . (self::MAX_SIZE / 1048576) . 'MB']);
|
||||
}
|
||||
|
||||
// 真实 MIME 二次校验(防改扩展名)
|
||||
$mime = (string) $file->getMime();
|
||||
if ($mime !== '' && preg_match('#^text/(x-)?php#i', $mime)) {
|
||||
return json(['code' => 0, 'msg' => '危险文件类型']);
|
||||
}
|
||||
|
||||
try {
|
||||
$storage = new FileStorageService();
|
||||
$result = $storage->upload($file, 'articles/' . date('Ymd'));
|
||||
|
||||
$record = ArticleAttachment::record(
|
||||
is_array($result) ? $result : ['url' => (string) $result],
|
||||
(int) ($this->auth->info['uid'] ?? 0),
|
||||
(int) Request::post('aid', 0)
|
||||
);
|
||||
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => '上传成功',
|
||||
'data' => ['id' => $record->id, 'url' => $record->url],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return json(['code' => 0, 'msg' => '上传失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除记录并尝试清理物理文件
|
||||
*/
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$id = (int) ($id ?: Request::post('id', 0));
|
||||
$att = ArticleAttachment::find($id);
|
||||
if (!$att) {
|
||||
return json(['code' => 0, 'msg' => '记录不存在']);
|
||||
}
|
||||
|
||||
// 删除物理文件(限定在 public 目录内,防目录穿越)
|
||||
$rel = ltrim((string) $att->path, '/');
|
||||
$url = ltrim((string) $att->url, '/');
|
||||
$root = rtrim((string) public_path(), DIRECTORY_SEPARATOR);
|
||||
|
||||
foreach ([$rel, $url] as $candidate) {
|
||||
if ($candidate === '' || strpos($candidate, '..') !== false) {
|
||||
continue;
|
||||
}
|
||||
$full = $root . DIRECTORY_SEPARATOR . $candidate;
|
||||
if (is_file($full)) {
|
||||
@unlink($full);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$att->delete();
|
||||
|
||||
return json(['code' => 1, 'msg' => '已删除']);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace addon\articles\controller\backend;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Request;
|
||||
use think\facade\View;
|
||||
use think\Validate;
|
||||
@@ -51,35 +52,26 @@ class Category extends BackendBase
|
||||
'page' => $page,
|
||||
'list_rows' => $size,
|
||||
]);
|
||||
$this->result->success($data->items());
|
||||
// 必须回传 count,否则 layui 表格分页失效
|
||||
return json(['code' => 0, 'msg' => '', 'count' => $data->total(), 'data' => $data->items()]);
|
||||
}
|
||||
$this->view->assign('title', '登录');
|
||||
return $this->view->fetch('category/index');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if (Request::isPost()) {
|
||||
$data = Request::post();
|
||||
$data['status'] = isset($data['status']) ? 1 : 0;
|
||||
|
||||
$validate = new Validate([
|
||||
'name|名称' => 'require|max:50',
|
||||
'alias|别名' => 'require|max:50',
|
||||
]);
|
||||
if (!$validate->check($data)) {
|
||||
$this->result->error($validate->getError());
|
||||
}
|
||||
ArticleCategory::create($data);
|
||||
$this->result->success();
|
||||
return $this->update(0);
|
||||
}
|
||||
// GET:渲染新增表单(原实现无返回值,会输出空白页)
|
||||
return View::fetch('category/edit', ['category' => null]);
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$category = ArticleCategory::find($id);
|
||||
if (!$category) {
|
||||
return redirect('/articles/backend/category');
|
||||
return redirect((string) url('articles/backend/category/index'));
|
||||
}
|
||||
return View::fetch('category/edit', ['category' => $category]);
|
||||
}
|
||||
@@ -99,17 +91,33 @@ class Category extends BackendBase
|
||||
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
$category = ArticleCategory::find($id);
|
||||
if (!$category) {
|
||||
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||
// 字段白名单(category 表实际列)
|
||||
$allow = ['name', 'alias', 'description', 'cover', 'sort', 'status', 'create_at', 'update_at'];
|
||||
|
||||
try {
|
||||
if ($id) {
|
||||
$category = ArticleCategory::find($id);
|
||||
if (!$category) {
|
||||
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||
}
|
||||
$category->allowField($allow)->save($data);
|
||||
} else {
|
||||
$data['create_at'] = time();
|
||||
ArticleCategory::create($data, $allow);
|
||||
}
|
||||
$category->save($data);
|
||||
} else {
|
||||
$data['create_at'] = time();
|
||||
ArticleCategory::create($data);
|
||||
} catch (\Throwable $e) {
|
||||
// alias 上有唯一键 uk_alias,重复会抛异常,转友好提示
|
||||
return json(['code' => 0, 'msg' => '保存失败:别名可能已存在']);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => '保存成功', 'url' => '/articles/backend/category']);
|
||||
|
||||
// 前台侧边栏分类有 5 分钟缓存,写操作后立即失效
|
||||
Cache::delete('articles:categories');
|
||||
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => '保存成功',
|
||||
'url' => (string) url('articles/backend/category/index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
@@ -121,7 +129,8 @@ class Category extends BackendBase
|
||||
return json(['code' => 0, 'msg' => '该分类下还有文章,无法删除']);
|
||||
}
|
||||
$category->delete();
|
||||
return json(['code' => 0, 'msg' => '已删除']);
|
||||
Cache::delete('articles:categories');
|
||||
return json(['code' => 1, 'msg' => '已删除']);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ use addon\articles\library\ArticleSchema;
|
||||
|
||||
class Comment extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = ['*'];
|
||||
// 后台操作走菜单权限节点校验,不放开免校验白名单
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
@@ -64,9 +65,13 @@ class Comment extends BackendBase
|
||||
$id = $id ?: (int) Request::post('id', 0);
|
||||
$comment = ArticleComment::find($id);
|
||||
if ($comment) {
|
||||
// 只在「未通过 → 通过」时累加计数:重复审核不会把评论数刷高
|
||||
$wasPending = (int) $comment->status !== 1;
|
||||
$comment->status = 1;
|
||||
$comment->save();
|
||||
Article::where('id', $comment->aid)->inc('comment_count')->update();
|
||||
if ($wasPending) {
|
||||
Article::where('id', $comment->aid)->inc('comment_count')->update();
|
||||
}
|
||||
return json(['code' => 1, 'msg' => '审核通过']);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||
@@ -78,6 +83,8 @@ class Comment extends BackendBase
|
||||
$comment = ArticleComment::find($id);
|
||||
if ($comment) {
|
||||
$comment->delete();
|
||||
// 同步回退文章评论数,保持计数准确
|
||||
Article::where('id', $comment->aid)->where('comment_count', '>', 0)->dec('comment_count')->update();
|
||||
return json(['code' => 1, 'msg' => '已删除']);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?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\articles\controller\backend;
|
||||
|
||||
use addon\articles\library\ArticleSchema;
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
use addon\articles\model\ArticleCategory;
|
||||
use addon\articles\model\ArticleComment;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* 文章CMS 后台仪表盘(统计概览)
|
||||
*/
|
||||
class Dashboard extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
parent::initialize();
|
||||
ArticleSchema::ensure();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$today = strtotime('today');
|
||||
|
||||
$stats = [
|
||||
'total' => ArticleModel::count(),
|
||||
'published' => ArticleModel::where('status', 1)->count(),
|
||||
'draft' => ArticleModel::where('status', 0)->count(),
|
||||
'pending' => ArticleModel::scope('pending')->count(),
|
||||
'today' => ArticleModel::where('create_at', '>=', $today)->count(),
|
||||
'views' => (int) ArticleModel::sum('view_count'),
|
||||
'comments' => ArticleComment::count(),
|
||||
'pending_comments' => ArticleComment::where('status', 0)->count(),
|
||||
'trashed' => ArticleModel::onlyTrashed()->count(),
|
||||
];
|
||||
|
||||
// 分类分布(Top 10)
|
||||
$dist = [];
|
||||
foreach (ArticleCategory::where('delete_at', 0)->order('sort', 'desc')->select() as $c) {
|
||||
$dist[] = [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'count' => ArticleModel::where('cid', $c->id)->count(),
|
||||
];
|
||||
}
|
||||
usort($dist, function ($a, $b) {
|
||||
return $b['count'] <=> $a['count'];
|
||||
});
|
||||
$dist = array_slice($dist, 0, 10);
|
||||
|
||||
// 热门文章 Top 10
|
||||
$hot = ArticleModel::where('status', 1)
|
||||
->field('id,title,view_count,comment_count')
|
||||
->order('view_count', 'desc')
|
||||
->limit(10)
|
||||
->select();
|
||||
|
||||
return $this->view->fetch('dashboard/index', [
|
||||
'stats' => $stats,
|
||||
'dist' => $dist,
|
||||
'hot' => $hot,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?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\articles\controller\backend;
|
||||
|
||||
use addon\articles\library\ArticleSchema;
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
use addon\articles\model\ArticleTopic;
|
||||
use addon\articles\model\ArticleTopicArticle;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Request;
|
||||
use think\facade\View;
|
||||
use think\Validate;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* 文章专题管理(运营聚合页)
|
||||
*/
|
||||
class Topic extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
parent::initialize();
|
||||
ArticleSchema::ensure();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if (Request::isAjax()) {
|
||||
$page = Request::param('page', 1);
|
||||
$limit = Request::param('limit', 15);
|
||||
|
||||
$paginator = ArticleTopic::order('sort', 'desc')->order('id', 'desc')
|
||||
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||
|
||||
$rows = [];
|
||||
foreach ($paginator->items() as $t) {
|
||||
$rows[] = [
|
||||
'id' => $t->id,
|
||||
'title' => $t->title,
|
||||
'alias' => $t->alias,
|
||||
'sort' => (int) $t->sort,
|
||||
'status' => $t->status,
|
||||
'article_count' => ArticleTopicArticle::where('tid', $t->id)->count(),
|
||||
'create_at' => $t->create_at,
|
||||
'edit_url' => (string) url('articles/backend/topic/edit', ['id' => $t->id]),
|
||||
];
|
||||
}
|
||||
|
||||
return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]);
|
||||
}
|
||||
|
||||
return View::fetch('topic/index');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if (Request::isPost()) {
|
||||
return $this->update(0);
|
||||
}
|
||||
return View::fetch('topic/edit', ['topic' => null, 'picked' => []]);
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$topic = ArticleTopic::find($id);
|
||||
if (!$topic) {
|
||||
return redirect((string) url('articles/backend/topic/index'));
|
||||
}
|
||||
|
||||
$ids = ArticleTopicArticle::articleIdsOf((int) $topic->id);
|
||||
$picked = $ids
|
||||
? ArticleModel::whereIn('id', $ids)->field('id,title')->select()->toArray()
|
||||
: [];
|
||||
|
||||
return View::fetch('topic/edit', ['topic' => $topic, 'picked' => $picked]);
|
||||
}
|
||||
|
||||
public function update($id = 0)
|
||||
{
|
||||
$data = Request::post();
|
||||
$id = $id ?: (int) ($data['id'] ?? 0);
|
||||
$data['update_at'] = time();
|
||||
$data['status'] = isset($data['status']) ? 1 : 0;
|
||||
|
||||
$validate = new Validate([
|
||||
'title|标题' => 'require|max:100',
|
||||
'alias|别名' => 'require|max:50',
|
||||
]);
|
||||
if (!$validate->check($data)) {
|
||||
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||
}
|
||||
|
||||
// 关联文章:逗号分隔的文章ID(顺序即专题内排序)
|
||||
$articleIds = array_values(array_filter(array_map('intval', explode(',', (string) ($data['article_ids'] ?? '')))));
|
||||
|
||||
$allow = ['title', 'alias', 'description', 'cover', 'sort', 'status', 'create_at', 'update_at'];
|
||||
|
||||
try {
|
||||
if ($id) {
|
||||
$topic = ArticleTopic::find($id);
|
||||
if (!$topic) {
|
||||
return json(['code' => 0, 'msg' => '专题不存在']);
|
||||
}
|
||||
$topic->allowField($allow)->save($data);
|
||||
} else {
|
||||
$data['create_at'] = time();
|
||||
$topic = ArticleTopic::create($data, $allow);
|
||||
$id = $topic->id;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// alias 上有唯一键 uk_alias
|
||||
return json(['code' => 0, 'msg' => '保存失败:别名可能已存在']);
|
||||
}
|
||||
|
||||
ArticleTopicArticle::syncArticles((int) $id, $articleIds);
|
||||
|
||||
// 前台专题聚合页有缓存,写操作后立即失效
|
||||
Cache::delete('articles:topics');
|
||||
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => '保存成功',
|
||||
'url' => (string) url('articles/backend/topic/index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$id = (int) ($id ?: Request::post('id', 0));
|
||||
$topic = ArticleTopic::find($id);
|
||||
if (!$topic) {
|
||||
return json(['code' => 0, 'msg' => '专题不存在']);
|
||||
}
|
||||
|
||||
$topic->delete();
|
||||
ArticleTopicArticle::where('tid', $id)->delete();
|
||||
Cache::delete('articles:topics');
|
||||
|
||||
return json(['code' => 1, 'msg' => '已删除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?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\articles\controller\member;
|
||||
|
||||
use addon\articles\library\HtmlSanitizer;
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
use addon\articles\model\ArticleCategory;
|
||||
use think\Validate;
|
||||
use ywxapp\controller\MemberBase;
|
||||
|
||||
/**
|
||||
* 会员中心 · 我的投稿
|
||||
*
|
||||
* 投稿统一写入 status=2(待审核),由后台审核通过后(status=1)才对前台可见。
|
||||
* 会员只能查看/编辑/删除【自己】的文章。
|
||||
*/
|
||||
class Article extends MemberBase
|
||||
{
|
||||
/** 投稿必须登录 */
|
||||
protected $noNeedLogin = [];
|
||||
|
||||
/**
|
||||
* 我的投稿列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
$list = ArticleModel::where('uid', $uid)
|
||||
->field(ArticleModel::listFields())
|
||||
->order('id', 'desc')
|
||||
->paginate(['list_rows' => 10, 'page' => (int) input('page', 1)]);
|
||||
|
||||
return $this->fetch('article/index', ['list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写新稿件
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
return $this->fetch('article/edit', [
|
||||
'article' => null,
|
||||
'categories' => $this->categories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑自己的稿件
|
||||
*/
|
||||
public function edit($id = 0)
|
||||
{
|
||||
$article = ArticleModel::where('id', (int) $id)->where('uid', $this->uid())->find();
|
||||
if (!$article) {
|
||||
$this->result->error('文章不存在或不属于你');
|
||||
}
|
||||
|
||||
return $this->fetch('article/edit', [
|
||||
'article' => $article,
|
||||
'categories' => $this->categories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存投稿(新增或编辑,保存后一律置为待审核)
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return redirect((string) url('articles/member/article/index'));
|
||||
}
|
||||
|
||||
$uid = $this->uid();
|
||||
$data = $this->request->post();
|
||||
$id = (int) ($data['id'] ?? 0);
|
||||
|
||||
$validate = new Validate([
|
||||
'title|标题' => 'require|max:255',
|
||||
'content|内容' => 'require',
|
||||
'cid|分类' => 'require|number',
|
||||
]);
|
||||
if (!$validate->check($data)) {
|
||||
$this->result->error($validate->getError());
|
||||
}
|
||||
|
||||
// 正文必须净化(前台来源不可信)
|
||||
$data['content'] = HtmlSanitizer::clean((string) $data['content']);
|
||||
$data['status'] = 2; // 待审核
|
||||
$data['uid'] = $uid;
|
||||
$data['author'] = $this->auth->info['nickname'] ?? '';
|
||||
$data['is_top'] = 0;
|
||||
$data['is_recommend'] = 0;
|
||||
$data['update_at'] = time();
|
||||
|
||||
$allow = ['title', 'content', 'summary', 'cover_image', 'uid', 'author',
|
||||
'cid', 'status', 'is_top', 'is_recommend', 'create_at', 'update_at'];
|
||||
|
||||
if ($id) {
|
||||
$article = ArticleModel::where('id', $id)->where('uid', $uid)->find();
|
||||
if (!$article) {
|
||||
$this->result->error('文章不存在或不属于你');
|
||||
}
|
||||
$article->allowField($allow)->save($data);
|
||||
} else {
|
||||
$data['create_at'] = time();
|
||||
ArticleModel::create($data, $allow);
|
||||
}
|
||||
|
||||
$this->result->success(null, '投稿已提交,等待管理员审核');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除自己的稿件(软删)
|
||||
*/
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$id = (int) ($id ?: input('id', 0));
|
||||
$article = ArticleModel::where('id', $id)->where('uid', $this->uid())->find();
|
||||
|
||||
if (!$article) {
|
||||
$this->result->error('文章不存在或不属于你');
|
||||
}
|
||||
|
||||
$article->delete();
|
||||
$this->result->success(null, '已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会员UID
|
||||
*/
|
||||
private function uid(): int
|
||||
{
|
||||
return (int) ($this->auth->info['uid'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选分类
|
||||
*/
|
||||
private function categories()
|
||||
{
|
||||
return ArticleCategory::where('delete_at', 0)->where('status', 1)->select();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user