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

+108 -2
View File
@@ -29,10 +29,12 @@ class MebmberScore extends BaseModel
protected function getOptions(): array
{
return [
'name' => 'member_score_rule',
'strict' => true,
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
'deleteTime' => 'delete_at',
'readonly' => ['view_count']
];
}
@@ -51,6 +53,10 @@ class MebmberScore extends BaseModel
/**
* 确保 score_rule / score_log 关联表已就绪(静态入口可能未实例化模型)
*
* 老库自愈:install.sql 建表后仍可能缺列(如规则引擎新增的 addon/limit_* 与
* 流水的 action/source/source_id),这里按 install.sql 的定义补齐,
* 并把历史遗留的 name 列改名为 title。
*/
public static function ensureSchema(): void
{
@@ -58,6 +64,93 @@ class MebmberScore extends BaseModel
BaseModel::ensureTableFromInstall($prefix, 'member_score_rule');
BaseModel::ensureTableFromInstall($prefix, 'member_score_log');
BaseModel::ensureColumn("{$prefix}member_user", 'score', "int unsigned NOT NULL DEFAULT 0 COMMENT '积分(做任务/互动获得)'");
$ruleTable = $prefix . 'member_score_rule';
$logTable = $prefix . 'member_score_log';
// 历史遗留字段改名:name -> title(仅当 name 存在且 title 不存在)
self::renameColumnIfNeeded(
$ruleTable,
'name',
'title',
"varchar(100) NOT NULL DEFAULT '' COMMENT '规则名称'"
);
// 规则表:规则引擎新增列
foreach ([
'addon' => "varchar(50) NOT NULL DEFAULT '' COMMENT '归属插件标识(空=核心)'",
'limit_type' => "tinyint(4) NOT NULL DEFAULT 0 COMMENT '限次周期 0不限1每日2每周3每月4累计'",
'limit_times' => "int(11) NOT NULL DEFAULT 0 COMMENT '周期内最多触发次数(0=不限)'",
'unique_type' => "tinyint(4) NOT NULL DEFAULT 0 COMMENT '去重 0不去重1按来源业务ID去重'",
'remark' => "varchar(255) NOT NULL DEFAULT '' COMMENT '流水备注模板({title}等占位)'",
] as $column => $def) {
BaseModel::ensureColumn($ruleTable, $column, $def);
}
// 流水表:溯源与去重列
foreach ([
'action' => "varchar(50) NOT NULL DEFAULT '' COMMENT '触发动作标识'",
'source' => "varchar(50) NOT NULL DEFAULT '' COMMENT '来源模块/插件'",
'source_id' => "varchar(64) NOT NULL DEFAULT '' COMMENT '来源业务ID(去重与追踪)'",
] as $column => $def) {
BaseModel::ensureColumn($logTable, $column, $def);
}
self::ensureIndex($ruleTable, 'idx_addon', '`addon`');
self::ensureIndex($logTable, 'idx_rule', '`rule_id`');
self::ensureIndex($logTable, 'idx_dedup', '`action`,`uid`,`source_id`');
}
/**
* 列改名(幂等):仅当旧列存在且新列不存在时执行 CHANGE
*/
protected static function renameColumnIfNeeded(string $table, string $from, string $to, string $def): void
{
try {
$hasFrom = Db::query(
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
[$table, $from]
);
if (empty($hasFrom)) {
return;
}
$hasTo = Db::query(
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
[$table, $to]
);
if (! empty($hasTo)) {
return;
}
Db::execute("ALTER TABLE `{$table}` CHANGE COLUMN `{$from}` `{$to}` {$def}");
} catch (\Throwable $e) {
try {
\think\facade\Log::error("[MebmberScore] rename {$table}.{$from} -> {$to} failed: " . $e->getMessage());
} catch (\Throwable $e2) {
// 日志失败则忽略
}
}
}
/**
* 索引不存在则追加(幂等)
*/
protected static function ensureIndex(string $table, string $index, string $columns): void
{
try {
$exists = Db::query(
"SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1",
[$table, $index]
);
if (empty($exists)) {
Db::execute("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})");
}
} catch (\Throwable $e) {
try {
\think\facade\Log::error("[MebmberScore] ensureIndex {$table}.{$index} failed: " . $e->getMessage());
} catch (\Throwable $e2) {
// 日志失败则忽略
}
}
}
/**
@@ -67,9 +160,10 @@ class MebmberScore extends BaseModel
* @param int $value 变动值(正数)
* @param string $remark 备注
* @param int $ruleId 关联规则ID(默认0)
* @param array $extra 溯源信息:action(动作标识)/ source(来源模块或插件)/ source_id(来源业务ID)
* @return array [bool, string]
*/
public static function change(int $uid, int $type, int $value, string $remark = '', int $ruleId = 0): array
public static function change(int $uid, int $type, int $value, string $remark = '', int $ruleId = 0, array $extra = []): array
{
self::ensureSchema();
$uid = (int) $uid;
@@ -97,9 +191,12 @@ class MebmberScore extends BaseModel
Db::name('member_score_log')->insert([
'uid' => $uid,
'rule_id' => $ruleId,
'action' => (string) ($extra['action'] ?? ''),
'type' => $type,
'value' => $value,
'balance' => $balance,
'source' => (string) ($extra['source'] ?? ''),
'source_id' => (string) ($extra['source_id'] ?? ''),
'remark' => $remark,
'create_at' => time(),
]);
@@ -115,13 +212,22 @@ class MebmberScore extends BaseModel
/**
* 积分流水列表(后台查看)
*/
public static function logList(int $uid = 0, int $page = 1, int $limit = 15): array
public static function logList(int $uid = 0, int $page = 1, int $limit = 15, array $filter = []): array
{
self::ensureSchema();
$query = Db::name('member_score_log');
if ($uid > 0) {
$query->where('uid', $uid);
}
if (! empty($filter['action'])) {
$query->where('action', (string) $filter['action']);
}
if (! empty($filter['source'])) {
$query->where('source', (string) $filter['source']);
}
if (! empty($filter['rule_id'])) {
$query->where('rule_id', (int) $filter['rule_id']);
}
$count = $query->count();
$list = $query->order('id', 'desc')
->page($page, $limit)