安全(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(市场 / 开发者中心 / 审核 / 分润)
314 lines
11 KiB
PHP
314 lines
11 KiB
PHP
<?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);
|
||
}
|
||
}
|