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

+313
View File
@@ -0,0 +1,313 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-09-28
* @Description: 积分规则引擎:动作 -> 规则 -> 限次/去重 -> 落账 -> 事件
* @FilePath: \ywxapp_dev\ywxapp\service\ScoreService.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
declare(strict_types=1);
namespace ywxapp\service;
use think\facade\Db;
use think\facade\Event;
use think\facade\Log;
use ywxapp\model\MebmberScore;
/**
* 积分规则引擎
*
* 链路:业务点/插件 --trigger()--> ScoreBefore 事件 --> 查规则 --> 限次/去重
* --> MebmberScore::change() 落账 --> ScoreAfter 事件
*
* 插件接入三种姿势:
* 1. 主动发放:ScoreService::trigger('插件自定义动作', $uid, $context, $options)
* 2. 改分/取消:订阅器 $eventPrefix='Score',onBefore() 返回 int(覆盖分值)
* 或 false / ['cancel'=>true](取消发放),也可返回 ['rules'=>[...]] 注入临时规则
* 3. 发放后联动:onAfter() 拿到 rule/value/uid 做通知、升级等
*
* 事件名与既有体系一致:前缀 Score + 方法名 => ScoreBefore / ScoreAfter
*/
class ScoreService
{
// 限次周期
const LIMIT_NONE = 0; // 不限
const LIMIT_DAY = 1; // 每日
const LIMIT_WEEK = 2; // 每周
const LIMIT_MONTH = 3; // 每月
const LIMIT_ALL = 4; // 累计(永久)
/**
* 动作注册表:action => ['title' => 名称, 'addon' => 归属插件]
* 核心动作在此预置,插件可用 registerAction() 追加自己的动作
*/
protected static array $actions = [
'user.register' => ['title' => '用户注册', 'addon' => ''],
'user.login' => ['title' => '每日登录', 'addon' => ''],
'user.activate' => ['title' => '开通会员', 'addon' => ''],
'task.complete' => ['title' => '完成任务', 'addon' => ''],
];
/**
* 限次周期选项
*/
public static function limitTypeList(): array
{
return [
self::LIMIT_NONE => '不限',
self::LIMIT_DAY => '每日',
self::LIMIT_WEEK => '每周',
self::LIMIT_MONTH => '每月',
self::LIMIT_ALL => '累计',
];
}
/**
* 去重方式选项
*/
public static function uniqueTypeList(): array
{
return [
0 => '不去重',
1 => '按来源业务ID去重',
];
}
/**
* 注册一个可用动作(插件在订阅器/Addon::enable 中调用,供后台下拉选择)
*/
public static function registerAction(string $action, string $title, string $addon = ''): void
{
if ($action === '') {
return;
}
static::$actions[$action] = ['title' => $title, 'addon' => $addon];
}
/**
* 全部可用动作(注册表 + 规则表中已使用但未注册的动作,避免历史数据丢失)
* @return array action => ['title'=>.., 'addon'=>..]
*/
public static function actions(): array
{
$list = static::$actions;
try {
MebmberScore::ensureSchema();
$used = Db::name('member_score_rule')
->where('delete_at', 0)
->distinct(true)
->column('action');
foreach ($used as $action) {
$action = (string) $action;
if ($action !== '' && ! isset($list[$action])) {
$list[$action] = ['title' => $action, 'addon' => ''];
}
}
} catch (\Throwable $e) {
Log::warning('[ScoreService] 读取规则表动作失败: ' . $e->getMessage());
}
return $list;
}
/**
* 触发积分规则
*
* @param string $action 动作标识,如 user.register / task.complete / demo.checkin
* @param int $uid 用户ID
* @param array $context 业务上下文,供插件与备注模板使用(如 ['task_id'=>1,'title'=>'签到'])
* @param array $options 可选项:
* source 来源模块/插件名(写入流水,便于溯源)
* source_id 来源业务ID(配合规则 unique_type=1 去重)
* value 指定分值(>0 时覆盖规则表 value,如任务按任务配置发奖)
* remark 覆盖备注
* force true 忽略限次校验
* @return array ['ok'=>bool, 'msg'=>string, 'value'=>int 净变动, 'balance'=>int 最新余额, 'rules'=>int 命中规则数]
*/
public static function trigger(string $action, int $uid, array $context = [], array $options = []): array
{
$result = ['ok' => true, 'msg' => 'ok', 'value' => 0, 'balance' => 0, 'rules' => 0];
if ($uid <= 0) {
return array_merge($result, ['ok' => false, 'msg' => '用户ID无效']);
}
if ($action === '') {
return array_merge($result, ['ok' => false, 'msg' => '动作标识不能为空']);
}
$source = (string) ($options['source'] ?? '');
$sourceId = (string) ($options['source_id'] ?? '');
$force = ! empty($options['force']);
$fixedValue = isset($options['value']) ? (int) $options['value'] : 0;
try {
MebmberScore::ensureSchema();
} catch (\Throwable $e) {
Log::error('[ScoreService] 积分表自愈失败: ' . $e->getMessage());
return array_merge($result, ['ok' => false, 'msg' => '积分表未就绪']);
}
// 命中规则:同一动作可配多条,全部执行(按 sort 降序、id 升序)
$rules = Db::name('member_score_rule')
->where('action', $action)
->where('status', 1)
->where('delete_at', 0)
->order('sort', 'desc')
->order('id', 'asc')
->select()
->toArray();
// ===== 事件 ScoreBefore:插件可注入规则 / 覆盖分值 / 取消发放 =====
$responses = Event::trigger('ScoreBefore', [
'action' => $action,
'uid' => $uid,
'context' => $context,
'source' => $source,
'source_id' => $sourceId,
'rules' => $rules,
]);
$cancel = false;
$overrideValue = null;
$injectRules = [];
foreach ((array) $responses as $resp) {
if ($resp === false) {
$cancel = true;
continue;
}
if (is_numeric($resp)) {
$overrideValue = (int) $resp;
continue;
}
if (is_array($resp)) {
if (isset($resp['value'])) {
$overrideValue = (int) $resp['value'];
}
if (! empty($resp['rules'])) {
$injectRules = array_merge($injectRules, (array) $resp['rules']);
}
if (! empty($resp['cancel'])) {
$cancel = true;
}
}
}
if ($cancel) {
return array_merge($result, ['ok' => true, 'msg' => '已被插件取消']);
}
if (! empty($injectRules)) {
$rules = array_merge($rules, $injectRules);
}
// ===== 逐条规则执行 =====
foreach ($rules as $rule) {
$value = $fixedValue > 0 ? $fixedValue : (int) ($rule['value'] ?? 0);
if ($overrideValue !== null) {
$value = $overrideValue;
}
if ($value <= 0) {
continue;
}
// 去重:unique_type=1 时同一来源业务ID只发一次(防回调重试/刷新重复发放)
if ((int) ($rule['unique_type'] ?? 0) === 1 && $sourceId !== '') {
$dup = Db::name('member_score_log')
->where('action', $action)
->where('uid', $uid)
->where('source_id', $sourceId)
->count();
if ($dup > 0) {
continue;
}
}
// 限次
if (! $force && ! self::withinLimit($rule, $uid)) {
continue;
}
$remark = isset($options['remark']) && $options['remark'] !== ''
? (string) $options['remark']
: self::buildRemark($rule, $context);
[$ok, $msg] = MebmberScore::change(
$uid,
(int) ($rule['type'] ?? MebmberScore::TYPE_GAIN),
$value,
$remark,
(int) ($rule['id'] ?? 0),
['action' => $action, 'source' => $source, 'source_id' => $sourceId]
);
if (! $ok) {
Log::warning('[ScoreService] 发放失败 uid=' . $uid . ' action=' . $action . ' rule=' . ($rule['id'] ?? 0) . ' - ' . $msg);
continue;
}
$result['value'] += ((int) ($rule['type'] ?? 1) === MebmberScore::TYPE_SPEND ? -$value : $value);
$result['rules']++;
// ===== 事件 ScoreAfter:发放后联动(通知/升级/成就等) =====
Event::trigger('ScoreAfter', [
'action' => $action,
'uid' => $uid,
'context' => $context,
'source' => $source,
'source_id' => $sourceId,
'rule' => $rule,
'value' => $value,
]);
}
if ($result['rules'] === 0 && $result['msg'] === 'ok') {
$result['msg'] = '无命中规则';
}
$result['balance'] = (int) Db::name('member_user')->where('uid', $uid)->value('score');
return $result;
}
/**
* 限次校验:周期内已发放次数 < 上限 才允许
*/
protected static function withinLimit(array $rule, int $uid): bool
{
$type = (int) ($rule['limit_type'] ?? self::LIMIT_NONE);
$times = (int) ($rule['limit_times'] ?? 0);
if ($type === self::LIMIT_NONE || $times <= 0) {
return true;
}
$query = Db::name('member_score_log')
->where('uid', $uid)
->where('rule_id', (int) ($rule['id'] ?? 0));
switch ($type) {
case self::LIMIT_DAY:
$query->where('create_at', '>=', strtotime('today'));
break;
case self::LIMIT_WEEK:
$query->where('create_at', '>=', strtotime('monday this week'));
break;
case self::LIMIT_MONTH:
$query->where('create_at', '>=', strtotime(date('Y-m-01 00:00:00')));
break;
case self::LIMIT_ALL:
default:
break;
}
return $query->count() < $times;
}
/**
* 生成流水备注:规则 remark 为模板,{key} 取 context 值;为空则用规则名称
*/
protected static function buildRemark(array $rule, array $context): string
{
$tpl = trim((string) ($rule['remark'] ?? ''));
if ($tpl === '') {
return (string) ($rule['title'] ?? '');
}
return preg_replace_callback('/\{(\w+)\}/', function ($m) use ($context) {
$key = $m[1];
return isset($context[$key]) ? (string) $context[$key] : '';
}, $tpl);
}
}