Files
YwxAppThink/ywxapp/service/ScoreService.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

314 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}