安全(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(市场 / 开发者中心 / 审核 / 分润)
318 lines
10 KiB
PHP
318 lines
10 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | 文章模型
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types = 1);
|
||
|
||
namespace addon\articles\model;
|
||
|
||
use think\facade\Cache;
|
||
use think\facade\Db;
|
||
use think\model\concern\SoftDelete;
|
||
use ywxapp\model\BaseModel;
|
||
|
||
class Article extends BaseModel
|
||
{
|
||
use SoftDelete;
|
||
|
||
protected $name = 'articles_article';
|
||
protected $deleteTime = 'delete_at';
|
||
protected $defaultSoftDelete = 0;
|
||
|
||
// 时间字段为 int 时间戳(与 install.sql 一致),开启自动维护
|
||
protected $autoWriteTimestamp = true;
|
||
protected $createTime = 'create_at';
|
||
protected $updateTime = 'update_at';
|
||
|
||
protected $type = [
|
||
'status' => '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();
|
||
}
|
||
}
|