'integer', 'is_top' => 'integer', 'is_recommend' => 'integer', 'view_count' => 'integer', 'comment_count'=> 'integer', 'like_count' => 'integer', 'publish_time' => 'integer', ]; /** * 发布中:未删除 + 已发布 + 已到定时发布时间 * * status 语义:1=发布 0=草稿 2=待审核(前台投稿) */ public function scopeNormal($query) { $query->where('delete_at', 0) ->where('status', 1) ->where(function ($q) { // 定时发布:0 表示立即发布,否则到点后才对外可见 $q->where('publish_time', 0)->whereOr('publish_time', '<=', time()); }); } /** * 待审核(前台投稿后等待后台审核) */ public function scopePending($query) { $query->where('delete_at', 0)->where('status', 2); } /** * 列表场景的字段列表(动态生成,排除 longtext content) * * 为什么不写死列名: * 1. 排除式 field('content', true) 与 bind 关联组合时会生成 SELECT `content`, * 列表所有字段变 null(实测踩过); * 2. 写死列名(如 like_count)在线上老库缺列时直接 Unknown column 报错。 * 因此改为按 SHOW COLUMNS 动态取列并排除 content,结果缓存 1 小时。 */ public static function listFields(): string { $cacheKey = 'articles:list_fields'; $fields = Cache::get($cacheKey); if (!is_array($fields) || empty($fields)) { $table = Db::name('articles_article')->getTable(); $cols = Db::query('SHOW COLUMNS FROM `' . $table . '`'); $fields = []; foreach ($cols as $col) { $name = $col['Field'] ?? ''; if ($name === '' || strtolower($name) === 'content') { continue; } $fields[] = '`' . $name . '`'; } Cache::set($cacheKey, $fields, 3600); } return implode(',', $fields); } public function category() { return $this->belongsTo(ArticleCategory::class, 'cid', 'id') ->bind(['category_name' => 'name', 'category_alias' => 'alias']); } public function author() { return $this->belongsTo(\ywxapp\model\MemberUser::class, 'uid', 'uid') ->bind(['author_name' => 'nickname']); } public function tags() { return $this->belongsToMany(ArticleTag::class, ArticleArticleTag::class, 'tid', 'aid'); } /** * 列表查询(支持分类/标签/关键词) */ public static function listArticles(array $where = [], int $page = 1, int $size = 10, string $order = 'is_top desc, create_at desc') { // 列表排除正文(longtext),字段由 listFields() 动态生成 $query = self::with(['category'])->field(self::listFields())->scope('normal'); if (!empty($where['cid'])) { $query->where('cid', $where['cid']); } if (!empty($where['tid'])) { $ids = ArticleArticleTag::where('tid', $where['tid'])->column('aid'); $query->whereIn('id', $ids ?: [0]); } if (!empty($where['keyword'])) { $kw = $where['keyword']; if (self::fulltextAvailable()) { // 全文索引可用:走 MATCH AGAINST(布尔模式 + 前缀通配) $query->whereRaw('MATCH(`title`,`summary`) AGAINST (? IN BOOLEAN MODE)', [$kw . '*']); } else { $query->where(function ($q) use ($kw) { $q->whereLike('title', "%{$kw}%")->whereOr('summary', 'like', "%{$kw}%"); }); } } if (!empty($where['is_recommend'])) { $query->where('is_recommend', 1); } return $query->orderRaw($order)->paginate([ 'list_rows' => $size, 'page' => $page, ]); } /** * 全文索引是否可用(配置开启 + 索引真实存在,结果缓存 1 小时) * * 不可用时搜索自动降级为 LIKE,不会报错。 */ public static function fulltextAvailable(): bool { $cfg = \ywxapp\service\AddonService::config('articles'); if (empty($cfg['search_fulltext'])) { return false; } $flag = Cache::get('articles:fulltext'); if ($flag === null) { $flag = 0; try { $table = Db::name('articles_article')->getTable(); foreach (Db::query("SHOW INDEX FROM `{$table}`") as $row) { if (($row['Index_type'] ?? '') === 'FULLTEXT') { $flag = 1; break; } } } catch (\Throwable $e) { $flag = 0; } Cache::set('articles:fulltext', $flag, 3600); } return (int) $flag === 1; } public static function getDetail(int $id) { return self::with(['category', 'tags', 'author']) ->scope('normal') ->find($id); } public static function prev(int $id, int $cid = 0) { $query = self::scope('normal')->where('id', '<', $id); if ($cid) { $query->where('cid', $cid); } return $query->order('id', 'desc')->field('id,title')->find(); } public static function next(int $id, int $cid = 0) { $query = self::scope('normal')->where('id', '>', $id); if ($cid) { $query->where('cid', $cid); } return $query->order('id', 'asc')->field('id,title')->find(); } public static function hot(int $limit = 10) { return self::scope('normal') ->order('view_count', 'desc') ->limit($limit) ->field('id,title,cid,view_count') ->select(); } /** * 相关推荐(打分排序 + 缓存 10 分钟) * * 打分维度: * - 标签重合:每个共同标签 +10(信号最强) * - 同分类:+5 * - 新鲜度:30 天内线性衰减,最多 +5 * - 热度:浏览量轻微加成,最多 +3 * * 候选池限制在「同分类最近 100 篇 + 有共同标签的最近 200 条关联」, * 并一次性批量取标签(避免 N+1),保证大数据量下也可控。 * * @return array 推荐文章数组(id/title/cid/view_count/score) */ public static function related(int $id, int $cid = 0, int $limit = 5): array { $limit = max(1, min((int) $limit, 20)); $key = 'articles:related:' . $id . ':' . $limit; $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $myTags = array_map('intval', ArticleArticleTag::where('aid', $id)->column('tid')); // 1) 候选池 $pool = []; $sameCat = self::scope('normal') ->where('id', '<>', $id) ->when($cid > 0, function ($q) use ($cid) { $q->where('cid', $cid); }) ->field('id,title,cid,create_at,view_count') ->order('create_at', 'desc') ->limit(100) ->select() ->toArray(); foreach ($sameCat as $row) { $pool[(int) $row['id']] = $row; } if ($myTags) { $aids = ArticleArticleTag::whereIn('tid', $myTags) ->where('aid', '<>', $id) ->limit(200) ->column('aid'); if ($aids) { $byTag = self::scope('normal') ->whereIn('id', $aids) ->field('id,title,cid,create_at,view_count') ->select() ->toArray(); foreach ($byTag as $row) { $pool[(int) $row['id']] = $row; } } } if (empty($pool)) { return []; } // 2) 批量取候选文章的标签(避免逐篇查询) $tagMap = []; foreach (ArticleArticleTag::whereIn('aid', array_keys($pool))->select()->toArray() as $row) { $tagMap[(int) $row['aid']][] = (int) $row['tid']; } // 3) 打分 $now = time(); $scored = []; foreach ($pool as $aid => $row) { $common = $myTags ? count(array_intersect($myTags, $tagMap[$aid] ?? [])) : 0; $created = $row['create_at'] ?? 0; $ts = is_numeric($created) ? (int) $created : (int) strtotime((string) $created); $days = max(0, ($now - $ts) / 86400); $score = $common * 10; $score += ($cid > 0 && (int) $row['cid'] === (int) $cid) ? 5 : 0; $score += max(0, 30 - $days) / 30 * 5; $score += min(((int) ($row['view_count'] ?? 0)) / 100, 3); $row['score'] = round($score, 2); $scored[] = $row; } usort($scored, function ($a, $b) { return $b['score'] <=> $a['score']; }); $result = array_slice($scored, 0, $limit); Cache::set($key, $result, 600); return $result; } public static function ensureSchema(): void { // 统一交给 ArticleSchema:它解析的是**插件自身** install.sql。 // 旧的 ensureTableFromInstall() 读的是核心 public/install/install.sql, // 本插件表不在其中,等于空操作。 \addon\articles\library\ArticleSchema::ensure(); } }