* @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); } }