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

+204 -18
View File
@@ -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;
}
}
+36 -2
View File
@@ -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);
}
}
+2 -1
View File
@@ -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);
-2
View File
@@ -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);
+166
View File
@@ -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');
}
}
+2 -1
View File
@@ -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);
+92
View File
@@ -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');
}
}
+229
View File
@@ -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;
}
}
+146 -23
View File
@@ -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' => '已删除']);
}
}
+34 -25
View File
@@ -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,
]);
}
}
+151
View File
@@ -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();
}
}