安全(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(市场 / 开发者中心 / 审核 / 分润)
277 lines
8.0 KiB
PHP
277 lines
8.0 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types = 1);
|
||
|
||
namespace addon\blog\model;
|
||
|
||
use think\facade\Cache;
|
||
use ywxapp\model\BaseModel;
|
||
use ywxapp\model\MemberUser;
|
||
|
||
class BlogArticle extends BaseModel
|
||
{
|
||
// 设置数据表名
|
||
protected $name = 'blog_article';
|
||
|
||
// 设置主键
|
||
protected $pk = 'id';
|
||
|
||
// 自动写入时间戳
|
||
protected $autoWriteTimestamp = 'int';
|
||
protected $createTime = 'create_at';
|
||
protected $updateTime = 'update_at';
|
||
|
||
// 定义允许写入的字段
|
||
protected $allowField = [
|
||
'title', 'content', 'summary', 'cover_image',
|
||
'uid', 'cid', 'status', 'audit_status', 'is_top',
|
||
'view_count', 'comment_count', 'like_count', 'favorite_count', 'share_count',
|
||
];
|
||
|
||
// 设置字段类型
|
||
protected $type = [
|
||
'id' => 'int',
|
||
'uid' => 'int',
|
||
'cid' => 'int',
|
||
'status' => 'int',
|
||
'audit_status' => 'int',
|
||
'is_top' => 'int',
|
||
'view_count' => 'int',
|
||
'comment_count' => 'int',
|
||
'like_count' => 'int',
|
||
'favorite_count' => 'int',
|
||
'share_count' => 'int',
|
||
'create_at' => 'int',
|
||
'update_at' => 'int',
|
||
];
|
||
|
||
// 内容修改器:还原实体后做白名单净化
|
||
//
|
||
// 正文在前台是 {$article.content|raw} 直出(富文本必须原样输出),
|
||
// 因此必须在入库前净化,否则会员投稿即可形成存储型 XSS。
|
||
|
||
public function setContentAttr($value)
|
||
{
|
||
return \addon\blog\library\HtmlSanitizer::clean(htmlspecialchars_decode((string) $value));
|
||
}
|
||
|
||
/**
|
||
* 写入后清理首页缓存
|
||
*
|
||
* 首页数据缓存 homepage_data_v2 原先没有失效机制,新文章最长 1 小时才可见。
|
||
* 放在模型事件里,后台审核/编辑、会员发文等所有入口都能自动失效。
|
||
*/
|
||
protected static function onAfterWrite($model)
|
||
{
|
||
\think\facade\Cache::delete('homepage_data_v2');
|
||
}
|
||
|
||
/**
|
||
* 列表场景的字段列表(动态生成,排除 longtext content)
|
||
*
|
||
* 为什么不写死列名、也不用 field('content', true):
|
||
* 1. 排除式 field() 与 bind 关联(user()/category())组合时,
|
||
* 会生成只剩 `content` 的 SQL,列表字段全部变空(实测踩过);
|
||
* 2. 写死列名在老库缺列时直接 Unknown column 报错。
|
||
* 因此改为按 SHOW COLUMNS 动态取列并排除 content,结果缓存 1 小时。
|
||
*/
|
||
public static function listFields(): string
|
||
{
|
||
$cacheKey = 'blog:list_fields';
|
||
$fields = \think\facade\Cache::get($cacheKey);
|
||
|
||
if (!is_array($fields) || empty($fields)) {
|
||
$table = \think\facade\Db::name('blog_article')->getTable();
|
||
$cols = \think\facade\Db::query('SHOW COLUMNS FROM `' . $table . '`');
|
||
|
||
$fields = [];
|
||
foreach ($cols as $col) {
|
||
$name = $col['Field'] ?? '';
|
||
if ($name === '' || strtolower($name) === 'content') {
|
||
continue;
|
||
}
|
||
$fields[] = '`' . $name . '`';
|
||
}
|
||
|
||
\think\facade\Cache::set($cacheKey, $fields, 3600);
|
||
}
|
||
|
||
return implode(',', $fields);
|
||
}
|
||
|
||
// 摘要写入修改器:提交时若留空,自动从正文截取并持久化
|
||
|
||
public function setSummaryAttr($value, $data)
|
||
{
|
||
$value = trim((string) $value);
|
||
if ($value !== '') {
|
||
return $value;
|
||
}
|
||
|
||
$content = $data['content'] ?? '';
|
||
$content = is_string($content) ? strip_tags($content) : '';
|
||
$content = trim($content);
|
||
|
||
return $content !== '' ? mb_substr($content, 0, 150) : '';
|
||
}
|
||
|
||
// 获取文章摘要(自动生成,兼容遗留未存摘要的文章)
|
||
|
||
public function getSummaryAttr($value, $data)
|
||
{
|
||
if ($value !== '' && $value !== null) {
|
||
return $value;
|
||
}
|
||
|
||
$content = is_string($data['content'] ?? '') ? (string) $data['content'] : '';
|
||
$content = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
$content = trim(preg_replace('/\s+/', ' ', $content));
|
||
|
||
// 正文为 JSON 或无可读文本时返回空,由模板显示「暂无描述」,避免首页展示 JSON 片段
|
||
if ($content === '' || $this->isJsonContent($content)) {
|
||
return '';
|
||
}
|
||
|
||
return mb_substr($content, 0, 150);
|
||
}
|
||
|
||
// 判断文本是否像 JSON(自动摘要时跳过,避免卡片展示 JSON 片段)
|
||
|
||
protected function isJsonContent($text)
|
||
{
|
||
$text = ltrim($text);
|
||
if ($text === '' || ($text[0] !== '{' && $text[0] !== '[')) {
|
||
return false;
|
||
}
|
||
json_decode($text);
|
||
return json_last_error() === JSON_ERROR_NONE;
|
||
}
|
||
|
||
// 格式化创建时间(兼容 int / 数字字符串 / 日期字符串 / DateTime)
|
||
|
||
public function getCreateTextAttr($value, $data)
|
||
{
|
||
$t = $data['create_at'] ?? $value;
|
||
if ($t instanceof \DateTime) {
|
||
return $t->format('Y-m-d H:i');
|
||
}
|
||
if (is_numeric($t)) {
|
||
return date('Y-m-d H:i', (int) $t);
|
||
}
|
||
$ts = strtotime((string) $t);
|
||
return $ts !== false ? date('Y-m-d H:i', $ts) : (string) $t;
|
||
}
|
||
|
||
// 定义关联 - 作者(博客作者即主系统会员,统一使用 wxapp_user 表,主键 uid)
|
||
|
||
public function author()
|
||
{
|
||
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
|
||
}
|
||
|
||
// 兼容别名:部分视图/调用使用 user 关系
|
||
|
||
public function user()
|
||
{
|
||
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
|
||
}
|
||
|
||
// 定义关联 - 分类
|
||
|
||
public function category()
|
||
{
|
||
return $this->belongsTo(BlogCategory::class, 'cid');
|
||
}
|
||
|
||
// 定义关联 - 标签(关联表 wxapp_blog_article_tag,列 aid/tid)
|
||
|
||
public function tags()
|
||
{
|
||
return $this->belongsToMany(BlogTag::class, 'blog_article_tag', 'tid', 'aid');
|
||
}
|
||
|
||
// 定义关联 - 评论
|
||
|
||
public function comments()
|
||
{
|
||
return $this->hasMany(BlogComment::class, 'aid');
|
||
}
|
||
|
||
// 定义关联 - 点赞
|
||
|
||
public function likes()
|
||
{
|
||
return $this->hasMany(BlogLike::class, 'aid');
|
||
}
|
||
|
||
// 定义关联 - 收藏
|
||
|
||
public function favorites()
|
||
{
|
||
return $this->hasMany(BlogFavorite::class, 'aid');
|
||
}
|
||
|
||
// 增加浏览次数
|
||
|
||
public function incrementViewCount()
|
||
{
|
||
$this->view_count++;
|
||
$this->save();
|
||
|
||
// 清除相关缓存
|
||
Cache::delete('article_' . $this->id);
|
||
}
|
||
|
||
// 检查文章是否置顶
|
||
|
||
public function isTop()
|
||
{
|
||
return $this->is_top === 1;
|
||
}
|
||
|
||
// 检查文章是否发布
|
||
|
||
public function isPublished()
|
||
{
|
||
return $this->status === 1;
|
||
}
|
||
|
||
// 获取相关文章(同分类)
|
||
|
||
public function getRelatedArticles($limit = 5)
|
||
{
|
||
return self::where('cid', $this->cid)
|
||
->where('id', '<>', $this->id)
|
||
->where('status', 1)
|
||
->order('view_count', 'desc')
|
||
->limit($limit)
|
||
->select();
|
||
}
|
||
|
||
// 获取上一篇/下一篇文章
|
||
|
||
public function getPrevNextArticles()
|
||
{
|
||
$prev = self::where('id', '<', $this->id)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
|
||
$next = self::where('id', '>', $this->id)
|
||
->where('status', 1)
|
||
->order('id', 'asc')
|
||
->find();
|
||
|
||
return [
|
||
'prev' => $prev,
|
||
'next' => $next,
|
||
];
|
||
}
|
||
}
|