// +---------------------------------------------------------------------- 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, ]; } }