* @Date: 2026-08-10 15:31:07 * @LastEditors: YwxApp * @LastEditTime: 2026-08-13 17:07:28 * @Description: * @FilePath: \ywxapp_dev\addon\articles\controller\Article.php * @CustomString: Copyright (c) 2026 YwxApp */ // +---------------------------------------------------------------------- // | YwxApp [ WE CAN DO IT JUST THINK ] // +---------------------------------------------------------------------- // | 文章CMS前台文章详情 / 搜索 / 评论 // +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\articles\controller; 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 { public function read($id) { $article = ArticleModel::getDetail((int) $id); if (!$article) { // 软 404 会被搜索引擎收录:显式返回 HTTP 404 return $this->result->setStatusCode(404)->error('文章不存在或已删除', 404); } // 浏览量 +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' => $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'); } // 兼容 route: articles/article/detail/:id public function detail($id) { return $this->read($id); } public function search() { $keyword = trim(input('keyword', '')); $page = (int) input('page', 1); $list = ArticleModel::listArticles(['keyword' => $keyword], $page, 10); $this->view->assign([ 'keyword' => $keyword, 'list' => $list->items(), 'total' => $list->total(), 'page' => $list->render(), 'nav_active' => '', ]); return $this->fetch('search/index'); } public function comment() { if (!$this->auth || !$this->auth->isLogin) { $this->result->error('请先登录后再评论'); } $aid = (int) input('aid', 0); $pid = (int) input('pid', 0); $content = trim((string) input('content', '')); if (!$aid || $content === '') { $this->result->error('参数错误'); } // 长度校验:过短视为灌水,过长直接拒绝 $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' => $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(), ]); // 只有直接显示的评论才累加计数(待审核的等审核通过时再加) 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; } }