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

+193 -17
View File
@@ -8,6 +8,8 @@ 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;
@@ -31,12 +33,61 @@ class Article extends BaseModel
'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);
$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()
@@ -61,7 +112,8 @@ class Article extends BaseModel
*/
public static function listArticles(array $where = [], int $page = 1, int $size = 10, string $order = 'is_top desc, create_at desc')
{
$query = self::with(['category'])->scope('normal');
// 列表排除正文(longtext),字段由 listFields() 动态生成
$query = self::with(['category'])->field(self::listFields())->scope('normal');
if (!empty($where['cid'])) {
$query->where('cid', $where['cid']);
@@ -72,9 +124,14 @@ class Article extends BaseModel
}
if (!empty($where['keyword'])) {
$kw = $where['keyword'];
$query->where(function ($q) use ($kw) {
$q->whereLike('title', "%{$kw}%")->whereOr('summary', 'like', "%{$kw}%");
});
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);
@@ -86,6 +143,38 @@ class Article extends BaseModel
]);
}
/**
* 全文索引是否可用(配置开启 + 索引真实存在,结果缓存 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'])
@@ -120,22 +209,109 @@ class Article extends BaseModel
->select();
}
public static function related(int $id, int $cid = 0, int $limit = 5)
/**
* 相关推荐(打分排序 + 缓存 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
{
$query = self::scope('normal')->where('id', '<>', $id);
if ($cid) {
$query->where('cid', $cid);
$limit = max(1, min((int) $limit, 20));
$key = 'articles:related:' . $id . ':' . $limit;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
return $query->order('create_at', 'desc')
->limit($limit)
->field('id,title,cid')
->select();
$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
{
$prefix = self::currentPrefix();
self::ensureTableFromInstall($prefix, 'articles_article');
self::ensureTableFromInstall($prefix, 'articles_article_tag');
// 统一交给 ArticleSchema:它解析的是**插件自身** install.sql。
// 旧的 ensureTableFromInstall() 读的是核心 public/install/install.sql,
// 本插件表不在其中,等于空操作。
\addon\articles\library\ArticleSchema::ensure();
}
}